diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-05-17 19:05:49 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-05-17 19:05:49 +0300 |
commit | 43a25d93ebdabea52f99b05e15b06250cd8f07d7 (patch) | |
tree | dceebdc68925362117480a5d672bcff122fb625b /app/assets/javascripts/super_sidebar | |
parent | 20c84b99005abd1c82101dfeff264ac50d2df211 (diff) |
Add latest changes from gitlab-org/gitlab@16-0-stable-eev16.0.0-rc42
Diffstat (limited to 'app/assets/javascripts/super_sidebar')
45 files changed, 3859 insertions, 242 deletions
diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher.vue b/app/assets/javascripts/super_sidebar/components/context_switcher.vue index f1ddb8290a0..ad2111140a1 100644 --- a/app/assets/javascripts/super_sidebar/components/context_switcher.vue +++ b/app/assets/javascripts/super_sidebar/components/context_switcher.vue @@ -1,83 +1,216 @@ <script> -import { GlAvatar, GlSearchBoxByType } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { GlDisclosureDropdown, GlSearchBoxByType, GlLoadingIcon, GlAlert } from '@gitlab/ui'; import { s__ } from '~/locale'; -import { contextSwitcherItems } from '../mock_data'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import searchUserProjectsAndGroups from '../graphql/queries/search_user_groups_and_projects.query.graphql'; +import { trackContextAccess, formatContextSwitcherItems } from '../utils'; +import { maxSize, applyMaxSize } from '../popper_max_size_modifier'; import NavItem from './nav_item.vue'; +import ProjectsList from './projects_list.vue'; +import GroupsList from './groups_list.vue'; +import ContextSwitcherToggle from './context_switcher_toggle.vue'; export default { + i18n: { + contextNavigation: s__('Navigation|Context navigation'), + switchTo: s__('Navigation|Switch context'), + searchPlaceholder: s__('Navigation|Search your projects or groups'), + searchingLabel: s__('Navigation|Retrieving search results'), + searchError: s__('Navigation|There was an error fetching search results.'), + }, + apollo: { + groupsAndProjects: { + query: searchUserProjectsAndGroups, + manual: true, + variables() { + return { + username: this.username, + search: this.searchString, + }; + }, + result(response) { + this.hasError = false; + try { + const { + data: { + projects: { nodes: projects }, + user: { + groups: { nodes: groups }, + }, + }, + } = response; + + this.projects = formatContextSwitcherItems(projects); + this.groups = formatContextSwitcherItems(groups); + } catch (e) { + this.handleError(e); + } + }, + error(e) { + this.handleError(e); + }, + skip() { + return !this.searchString; + }, + }, + }, components: { - GlAvatar, + GlDisclosureDropdown, + ContextSwitcherToggle, GlSearchBoxByType, + GlLoadingIcon, + GlAlert, NavItem, + ProjectsList, + GroupsList, }, - i18n: { - contextNavigation: s__('Navigation|Context navigation'), - switchTo: s__('Navigation|Switch to...'), - recentProjects: s__('Navigation|Recent projects'), - recentGroups: s__('Navigation|Recent groups'), + props: { + persistentLinks: { + type: Array, + required: true, + }, + username: { + type: String, + required: true, + }, + projectsPath: { + type: String, + required: true, + }, + groupsPath: { + type: String, + required: true, + }, + currentContext: { + type: Object, + required: false, + default: () => ({}), + }, + contextHeader: { + type: Object, + required: true, + }, }, - contextSwitcherItems, - viewAllProjectsItem: { - title: s__('Navigation|View all projects'), - link: '/projects', - icon: 'project', + data() { + return { + searchString: '', + projects: [], + groups: [], + hasError: false, + isOpen: false, + }; }, - viewAllGroupsItem: { - title: s__('Navigation|View all groups'), - link: '/groups', - icon: 'group', + computed: { + isSearch() { + return Boolean(this.searchString); + }, + isSearching() { + return this.$apollo.queries.groupsAndProjects.loading; + }, + }, + watch: { + isOpen(isOpen) { + this.$emit('toggle', isOpen); + + if (isOpen) { + this.focusInput(); + } + }, + }, + created() { + if (this.currentContext.namespace) { + trackContextAccess(this.username, this.currentContext); + } + }, + methods: { + close() { + this.$refs['disclosure-dropdown'].close(); + }, + focusInput() { + this.$refs['search-box'].focusInput(); + }, + handleError(e) { + Sentry.captureException(e); + this.hasError = true; + }, + onDisclosureDropdownShown() { + this.isOpen = true; + }, + onDisclosureDropdownHidden() { + this.isOpen = false; + }, + }, + DEFAULT_DEBOUNCE_AND_THROTTLE_MS, + popperOptions: { + modifiers: [maxSize, applyMaxSize], }, }; </script> <template> - <div> - <gl-search-box-by-type /> - <nav :aria-label="$options.i18n.contextNavigation"> - <ul class="gl-p-0 gl-list-style-none"> - <li> - <div aria-hidden="true" class="gl-font-weight-bold gl-px-3 gl-py-3"> - {{ $options.i18n.switchTo }} - </div> - <ul :aria-label="$options.i18n.switchTo" class="gl-p-0"> - <nav-item :item="$options.contextSwitcherItems.yourWork" /> - </ul> - </li> - <li> - <div aria-hidden="true" class="gl-font-weight-bold gl-px-3 gl-py-3"> - {{ $options.i18n.recentProjects }} - </div> - <ul :aria-label="$options.i18n.recentProjects" class="gl-p-0"> - <nav-item - v-for="project in $options.contextSwitcherItems.recentProjects" - :key="project.title" - :item="project" - > - <template #icon> - <gl-avatar shape="rect" :size="32" :src="project.avatar" /> - </template> - </nav-item> - <nav-item :item="$options.viewAllProjectsItem" /> - </ul> - </li> - <li> - <div aria-hidden="true" class="gl-font-weight-bold gl-px-3 gl-py-3"> - {{ $options.i18n.recentGroups }} - </div> - <ul :aria-label="$options.i18n.recentGroups" class="gl-p-0"> + <gl-disclosure-dropdown + ref="disclosure-dropdown" + class="context-switcher gl-w-full" + placement="center" + :popper-options="$options.popperOptions" + @shown="onDisclosureDropdownShown" + @hidden="onDisclosureDropdownHidden" + > + <template #toggle> + <context-switcher-toggle :context="contextHeader" :expanded="isOpen" /> + </template> + <div aria-hidden="true" class="gl-font-sm gl-font-weight-bold gl-px-4 gl-pt-3 gl-pb-4"> + {{ $options.i18n.switchTo }} + </div> + <div class="gl-p-1 gl-border-t gl-border-b gl-border-gray-50 gl-bg-white"> + <gl-search-box-by-type + ref="search-box" + v-model="searchString" + class="context-switcher-search-box" + :placeholder="$options.i18n.searchPlaceholder" + :debounce="$options.DEFAULT_DEBOUNCE_AND_THROTTLE_MS" + borderless + /> + </div> + <gl-loading-icon + v-if="isSearching" + class="gl-mt-5" + size="md" + :label="$options.i18n.searchingLabel" + /> + <gl-alert v-else-if="hasError" variant="danger" :dismissible="false" class="gl-m-2"> + {{ $options.i18n.searchError }} + </gl-alert> + <nav v-else :aria-label="$options.i18n.contextNavigation" data-qa-selector="context_navigation"> + <ul class="gl-p-0 gl-m-0 gl-list-style-none"> + <li v-if="!isSearch"> + <ul + :aria-label="$options.i18n.switchTo" + class="gl-border-b gl-border-gray-50 gl-px-0 gl-py-2" + > <nav-item - v-for="project in $options.contextSwitcherItems.recentGroups" - :key="project.title" - :item="project" - > - <template #icon> - <gl-avatar shape="rect" :size="32" :src="project.avatar" /> - </template> - </nav-item> - <nav-item :item="$options.viewAllGroupsItem" /> + v-for="item in persistentLinks" + :key="item.link" + :item="item" + :link-classes="{ [item.link_classes]: item.link_classes }" + /> </ul> </li> + <projects-list + :username="username" + :view-all-link="projectsPath" + :is-search="isSearch" + :search-results="projects" + /> + <groups-list + class="gl-border-t gl-border-gray-50" + :username="username" + :view-all-link="groupsPath" + :is-search="isSearch" + :search-results="groups" + /> </ul> </nav> - </div> + </gl-disclosure-dropdown> </template> diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue index b6f058f7aee..cfb7e7732e9 100644 --- a/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue +++ b/app/assets/javascripts/super_sidebar/components/context_switcher_toggle.vue @@ -1,5 +1,5 @@ <script> -import { GlTruncate, GlAvatar, GlCollapseToggleDirective, GlIcon } from '@gitlab/ui'; +import { GlTruncate, GlAvatar, GlIcon } from '@gitlab/ui'; export default { components: { @@ -7,10 +7,10 @@ export default { GlAvatar, GlIcon, }, - directives: { - CollapseToggle: GlCollapseToggleDirective, - }, props: { + /* + * Contains metadata about the current view, e.g. `id`, `title` and `avatar` + */ context: { type: Object, required: true, @@ -24,22 +24,39 @@ export default { collapseIcon() { return this.expanded ? 'chevron-up' : 'chevron-down'; }, + avatarShape() { + return this.context.avatar_shape || 'rect'; + }, }, }; </script> <template> <button - v-collapse-toggle.context-switcher type="button" - class="context-switcher-toggle gl-bg-transparent 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-pl-3 gl-pr-5 gl-h-8" + 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" > - <gl-avatar :size="32" shape="rect" :src="context.avatar" class="gl-mr-3" /> - <div class="gl-overflow-auto"> + <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-icon :name="collapseIcon" /> + <span class="gl-flex-grow-1 gl-text-right gl-mr-4"> + <gl-icon class="gl-text-gray-400" :name="collapseIcon" /> </span> </button> </template> diff --git a/app/assets/javascripts/super_sidebar/components/counter.vue b/app/assets/javascripts/super_sidebar/components/counter.vue index 62a1e5a6b20..a6f19ff95f3 100644 --- a/app/assets/javascripts/super_sidebar/components/counter.vue +++ b/app/assets/javascripts/super_sidebar/components/counter.vue @@ -1,5 +1,6 @@ <script> import { GlIcon } from '@gitlab/ui'; +import { highCountTrim } from '~/lib/utils/text_utility'; export default { components: { @@ -7,7 +8,7 @@ export default { }, props: { count: { - type: Number, + type: [Number, String], required: true, }, href: { @@ -31,6 +32,12 @@ export default { component() { return this.href ? 'a' : 'button'; }, + formattedCount() { + if (Number.isFinite(this.count)) { + return highCountTrim(this.count); + } + return this.count; + }, }, }; </script> @@ -40,9 +47,9 @@ export default { :is="component" :aria-label="ariaLabel" :href="href" - class="counter gl-display-block gl-flex-grow-1 gl-text-center gl-py-3 gl-bg-gray-10 gl-rounded-base gl-text-gray-900 gl-border gl-border-gray-a-08 gl-font-sm gl-hover-text-gray-900 gl-hover-text-decoration-none" + class="counter gl-display-block gl-flex-grow-1 gl-text-center gl-py-3 gl-bg-gray-10 gl-rounded-base gl-text-gray-900 gl-border-none gl-inset-border-1-gray-a-08 gl-line-height-1 gl-font-sm gl-hover-text-gray-900 gl-hover-text-decoration-none gl-focus--focus" > <gl-icon aria-hidden="true" :name="icon" /> - <span v-if="count" aria-hidden="true" class="gl-ml-1">{{ count }}</span> + <span v-if="count" aria-hidden="true" class="gl-ml-1">{{ formattedCount }}</span> </component> </template> diff --git a/app/assets/javascripts/super_sidebar/components/create_menu.vue b/app/assets/javascripts/super_sidebar/components/create_menu.vue index e92a6cbf5f5..fa6056aff5e 100644 --- a/app/assets/javascripts/super_sidebar/components/create_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/create_menu.vue @@ -1,11 +1,28 @@ <script> -import { GlDisclosureDropdown, GlTooltip } from '@gitlab/ui'; +import { + GlDisclosureDropdown, + GlTooltip, + GlDisclosureDropdownGroup, + GlDisclosureDropdownItem, +} from '@gitlab/ui'; +import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; import { __ } from '~/locale'; +import { + TOP_NAV_INVITE_MEMBERS_COMPONENT, + TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN, +} from '~/invite_members/constants'; +import { DROPDOWN_Y_OFFSET } from '../constants'; + +// Left offset required for the dropdown to be aligned with the super sidebar +const DROPDOWN_X_OFFSET = -147; export default { components: { GlDisclosureDropdown, + GlDisclosureDropdownGroup, + GlDisclosureDropdownItem, GlTooltip, + InviteMembersTrigger, }, i18n: { createNew: __('Create new...'), @@ -16,22 +33,74 @@ export default { required: true, }, }, + data() { + return { + dropdownOpen: false, + }; + }, + methods: { + isInvitedMembers(groupItem) { + return groupItem.component === TOP_NAV_INVITE_MEMBERS_COMPONENT; + }, + closeAndFocus() { + this.$refs.dropdown.closeAndFocus(); + }, + }, toggleId: 'create-menu-toggle', + popperOptions: { + modifiers: [ + { + name: 'offset', + options: { + offset: [DROPDOWN_X_OFFSET, DROPDOWN_Y_OFFSET], + }, + }, + ], + }, + TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN, }; </script> <template> <div> <gl-disclosure-dropdown + ref="dropdown" category="tertiary" icon="plus" - :items="groups" no-caret text-sr-only :toggle-text="$options.i18n.createNew" :toggle-id="$options.toggleId" - /> - <gl-tooltip :target="`#${$options.toggleId}`" placement="bottom" container="#super-sidebar"> + :popper-options="$options.popperOptions" + data-qa-selector="new_menu_toggle" + data-testid="new-menu-toggle" + @shown="dropdownOpen = true" + @hidden="dropdownOpen = false" + > + <gl-disclosure-dropdown-group + v-for="(group, index) in groups" + :key="group.name" + :bordered="index !== 0" + :group="group" + > + <template v-for="groupItem in group.items"> + <invite-members-trigger + v-if="isInvitedMembers(groupItem)" + :key="`${groupItem.text}-trigger`" + trigger-source="top-nav" + :trigger-element="$options.TRIGGER_ELEMENT_DISCLOSURE_DROPDOWN" + @modal-opened="closeAndFocus" + /> + <gl-disclosure-dropdown-item v-else :key="groupItem.text" :item="groupItem" /> + </template> + </gl-disclosure-dropdown-group> + </gl-disclosure-dropdown> + <gl-tooltip + v-if="!dropdownOpen" + :target="`#${$options.toggleId}`" + placement="bottom" + container="#super-sidebar" + > {{ $options.i18n.createNew }} </gl-tooltip> </div> diff --git a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue new file mode 100644 index 00000000000..11bf2ddbd30 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue @@ -0,0 +1,96 @@ +<script> +import * as Sentry from '@sentry/browser'; +import AccessorUtilities from '~/lib/utils/accessor'; +import { getTopFrequentItems, formatContextSwitcherItems } from '../utils'; +import ItemsList from './items_list.vue'; + +export default { + components: { + ItemsList, + }, + props: { + title: { + type: String, + required: true, + }, + pristineText: { + type: String, + required: true, + }, + storageKey: { + type: String, + required: true, + }, + maxItems: { + type: Number, + required: true, + }, + }, + data() { + return { + cachedFrequentItems: [], + }; + }, + computed: { + isEmpty() { + return !this.cachedFrequentItems.length; + }, + }, + created() { + this.getItemsFromLocalStorage(); + }, + 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)), + ); + + // Update the list + this.cachedFrequentItems = this.cachedFrequentItems.filter((i) => i.id !== item.id); + } catch (e) { + Sentry.captureException(e); + } + }, + }, +}; +</script> + +<template> + <li class="gl-py-3"> + <div + data-testid="list-title" + aria-hidden="true" + class="gl-display-flex gl-align-items-center gl-text-transform-uppercase gl-text-secondary gl-font-weight-semibold gl-font-xs gl-line-height-12 gl-letter-spacing-06em gl-my-3" + > + <span class="gl-flex-grow-1 gl-px-3">{{ title }}</span> + </div> + <div + v-if="isEmpty" + data-testid="empty-text" + class="gl-text-gray-500 gl-font-sm gl-my-3 gl-mx-3" + > + {{ pristineText }} + </div> + <items-list :aria-label="title" :items="cachedFrequentItems" @remove-item="handleItemRemove"> + <template #view-all-items> + <slot name="view-all-items"></slot> + </template> + </items-list> + </li> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue new file mode 100644 index 00000000000..55c28661440 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue @@ -0,0 +1,316 @@ +<script> +import { + GlSearchBoxByType, + GlOutsideDirective as Outside, + GlIcon, + GlToken, + GlTooltipDirective, + GlResizeObserverDirective, + GlModal, +} from '@gitlab/ui'; +import { mapState, mapActions, mapGetters } from 'vuex'; +import { debounce, clamp } from 'lodash'; +import { truncate } from '~/lib/utils/text_utility'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { sprintf } from '~/locale'; +import { ARROW_DOWN_KEY, ARROW_UP_KEY, END_KEY, HOME_KEY, ESC_KEY } from '~/lib/utils/keys'; +import { + MIN_SEARCH_TERM, + SEARCH_GITLAB, + SEARCH_DESCRIBED_BY_WITH_RESULTS, + SEARCH_DESCRIBED_BY_DEFAULT, + SEARCH_DESCRIBED_BY_UPDATED, + SEARCH_RESULTS_LOADING, + SEARCH_RESULTS_SCOPE, +} from '~/vue_shared/global_search/constants'; +import { + SEARCH_INPUT_DESCRIPTION, + SEARCH_RESULTS_DESCRIPTION, + SEARCH_SHORTCUTS_MIN_CHARACTERS, + SCOPE_TOKEN_MAX_LENGTH, + INPUT_FIELD_PADDING, + IS_SEARCHING, + SEARCH_MODAL_ID, + SEARCH_INPUT_SELECTOR, + SEARCH_RESULTS_ITEM_SELECTOR, +} from '../constants'; +import GlobalSearchAutocompleteItems from './global_search_autocomplete_items.vue'; +import GlobalSearchDefaultItems from './global_search_default_items.vue'; +import GlobalSearchScopedItems from './global_search_scoped_items.vue'; + +export default { + name: 'GlobalSearchModal', + SEARCH_MODAL_ID, + i18n: { + SEARCH_GITLAB, + SEARCH_DESCRIBED_BY_WITH_RESULTS, + SEARCH_DESCRIBED_BY_DEFAULT, + SEARCH_DESCRIBED_BY_UPDATED, + SEARCH_RESULTS_LOADING, + SEARCH_RESULTS_SCOPE, + MIN_SEARCH_TERM, + }, + directives: { Outside, GlTooltip: GlTooltipDirective, GlResizeObserverDirective }, + components: { + GlSearchBoxByType, + GlobalSearchDefaultItems, + GlobalSearchScopedItems, + GlobalSearchAutocompleteItems, + GlIcon, + GlToken, + GlModal, + }, + computed: { + ...mapState(['search', 'loading', 'searchContext']), + ...mapGetters(['searchQuery', 'searchOptions', 'scopedSearchOptions']), + searchText: { + get() { + return this.search; + }, + set(value) { + this.setSearch(value); + }, + }, + showDefaultItems() { + return !this.searchText; + }, + searchTermOverMin() { + return this.searchText?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS; + }, + showScopedSearchItems() { + return this.searchTermOverMin && this.scopedSearchOptions.length > 1; + }, + searchResultsDescription() { + if (this.showDefaultItems) { + return sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_DEFAULT, { + count: this.searchOptions.length, + }); + } + + if (!this.searchTermOverMin) { + return this.$options.i18n.MIN_SEARCH_TERM; + } + + return this.loading + ? this.$options.i18n.SEARCH_RESULTS_LOADING + : sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_UPDATED, { + count: this.searchOptions.length, + }); + }, + searchBarClasses() { + return { + [IS_SEARCHING]: this.searchTermOverMin, + }; + }, + showScopeHelp() { + return this.searchTermOverMin; + }, + searchBarItem() { + return this.searchOptions?.[0]; + }, + infieldHelpContent() { + return this.searchBarItem?.scope || this.searchBarItem?.description; + }, + infieldHelpIcon() { + return this.searchBarItem?.icon; + }, + scopeTokenTitle() { + return sprintf(this.$options.i18n.SEARCH_RESULTS_SCOPE, { + scope: this.infieldHelpContent, + }); + }, + }, + methods: { + ...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']), + getAutocompleteOptions: debounce(function debouncedSearch(searchTerm) { + if (!searchTerm) { + this.clearAutocomplete(); + } else { + this.fetchAutocompleteOptions(); + } + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS), + getTruncatedScope(scope) { + return truncate(scope, SCOPE_TOKEN_MAX_LENGTH); + }, + observeTokenWidth({ contentRect: { width } }) { + const inputField = this.$refs?.searchInputBox?.$el?.querySelector('input'); + if (!inputField) { + return; + } + inputField.style.paddingRight = `${width + INPUT_FIELD_PADDING}px`; + }, + getFocusableOptions() { + return Array.from( + this.$refs.resultsList?.querySelectorAll(SEARCH_RESULTS_ITEM_SELECTOR) || [], + ); + }, + onKeydown(event) { + const { code, target } = event; + + let stop = true; + + const elements = this.getFocusableOptions(); + if (elements.length < 1) return; + + const isSearchInput = target.matches(SEARCH_INPUT_SELECTOR); + + if (code === HOME_KEY) { + this.focusItem(0, elements); + } else if (code === END_KEY) { + this.focusItem(elements.length - 1, elements); + } else if (code === ARROW_UP_KEY) { + if (isSearchInput) return; + + if (elements.indexOf(target) === 0) { + this.focusSearchInput(); + return; + } + this.focusNextItem(event, elements, -1); + } else if (code === ARROW_DOWN_KEY) { + this.focusNextItem(event, elements, 1); + } else if (code === ESC_KEY) { + this.$refs.searchModal.close(); + } else { + stop = false; + } + + if (stop) { + event.preventDefault(); + } + }, + focusSearchInput() { + this.$refs.searchInputBox.$el.querySelector('input').focus(); + }, + focusNextItem(event, elements, offset) { + const { target } = event; + const currentIndex = elements.indexOf(target); + const nextIndex = clamp(currentIndex + offset, 0, elements.length - 1); + + this.focusItem(nextIndex, elements); + }, + focusItem(index, elements) { + this.nextFocusedItemIndex = index; + + elements[index]?.focus(); + }, + submitSearch() { + if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS) { + return; + } + visitUrl(this.searchQuery); + }, + }, + SEARCH_INPUT_DESCRIPTION, + SEARCH_RESULTS_DESCRIPTION, +}; +</script> + +<template> + <gl-modal + ref="searchModal" + :modal-id="$options.SEARCH_MODAL_ID" + hide-header + hide-footer + hide-header-close + scrollable + body-class="gl-p-0!" + modal-class="global-search-modal" + :centered="false" + @hidden="$emit('hidden')" + @shown="$emit('shown')" + > + <form + role="search" + :aria-label="$options.i18n.SEARCH_GITLAB" + class="gl-relative gl-rounded-base gl-w-full" + :class="searchBarClasses" + data-testid="global-search-form" + > + <div class="gl-p-1"> + <gl-search-box-by-type + id="search" + ref="searchInputBox" + v-model="searchText" + role="searchbox" + data-testid="global-search-input" + data-qa-selector="global_search_input" + autocomplete="off" + :placeholder="$options.i18n.SEARCH_GITLAB" + :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION" + borderless + @input="getAutocompleteOptions" + @keydown.enter.stop.prevent="submitSearch" + @keydown="onKeydown" + /> + <gl-token + v-if="showScopeHelp" + v-gl-resize-observer-directive="observeTokenWidth" + class="in-search-scope-help gl-sm-display-block gl-display-none" + view-only + :title="scopeTokenTitle" + > + <gl-icon + v-if="infieldHelpIcon" + class="gl-mr-2" + :aria-label="infieldHelpContent" + :name="infieldHelpIcon" + :size="16" + /> + {{ + getTruncatedScope( + sprintf($options.i18n.SEARCH_RESULTS_SCOPE, { scope: infieldHelpContent }), + ) + }} + </gl-token> + <span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only"> + {{ $options.i18n.SEARCH_DESCRIBED_BY_WITH_RESULTS }} + </span> + </div> + <span + role="region" + :data-testid="$options.SEARCH_RESULTS_DESCRIPTION" + class="gl-sr-only" + aria-live="polite" + aria-atomic="true" + > + {{ searchResultsDescription }} + </span> + <div + ref="resultsList" + data-testid="global-search-results" + class="global-search-results gl-overflow-y-auto gl-w-full gl-pb-2" + @keydown="onKeydown" + > + <global-search-default-items v-if="showDefaultItems" /> + <template v-else> + <global-search-scoped-items v-if="showScopedSearchItems" /> + <global-search-autocomplete-items /> + </template> + </div> + + <template v-if="searchContext"> + <input + v-if="searchContext.group" + type="hidden" + name="group_id" + :value="searchContext.group.id" + /> + <input + v-if="searchContext.project" + type="hidden" + name="project_id" + :value="searchContext.project.id" + /> + + <template v-if="searchContext.group || searchContext.project"> + <input type="hidden" name="scope" :value="searchContext.scope" /> + <input type="hidden" name="search_code" :value="searchContext.code_search" /> + </template> + + <input type="hidden" name="snippets" :value="searchContext.for_snippets" /> + <input type="hidden" name="repository_ref" :value="searchContext.ref" /> + </template> + </form> + </gl-modal> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue new file mode 100644 index 00000000000..cd623200b03 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue @@ -0,0 +1,90 @@ +<script> +import { GlAvatar, GlAlert, GlLoadingIcon, GlDisclosureDropdownGroup } from '@gitlab/ui'; +import { mapState, mapGetters } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import highlight from '~/lib/utils/highlight'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; +import { AUTOCOMPLETE_ERROR_MESSAGE } from '~/vue_shared/global_search/constants'; + +export default { + name: 'GlobalSearchAutocompleteItems', + i18n: { + AUTOCOMPLETE_ERROR_MESSAGE, + }, + components: { + GlAvatar, + GlAlert, + GlLoadingIcon, + GlDisclosureDropdownGroup, + }, + directives: { + SafeHtml, + }, + computed: { + ...mapState(['search', 'loading', 'autocompleteError']), + ...mapGetters(['autocompleteGroupedSearchOptions', 'scopedSearchOptions']), + isPrecededByScopedOptions() { + return this.scopedSearchOptions.length > 1; + }, + }, + methods: { + highlightedName(val) { + return highlight(val, this.search); + }, + }, + AVATAR_SHAPE_OPTION_RECT, +}; +</script> + +<template> + <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" + :key="group.name" + :class="{ 'gl-mt-0!': !isPrecededByScopedOptions }" + :group="group" + bordered + > + <template #list-item="{ item }"> + <div class="gl-display-flex gl-align-items-center"> + <gl-avatar + v-if="item.avatar_url !== undefined" + class="gl-mr-3" + :src="item.avatar_url" + :entity-id="item.entity_id" + :entity-name="item.entity_name" + :size="item.avatar_size" + :shape="$options.AVATAR_SHAPE_OPTION_RECT" + aria-hidden="true" + /> + <span class="gl-display-flex gl-flex-direction-column"> + <span + v-safe-html="highlightedName(item.text)" + class="gl-text-gray-900" + data-testid="autocomplete-item-name" + ></span> + <span + v-if="item.value" + v-safe-html="item.namespace" + class="gl-font-sm gl-text-gray-500" + data-testid="autocomplete-item-namespace" + ></span> + </span> + </div> + </template> + </gl-disclosure-dropdown-group> + </ul> + + <gl-loading-icon v-else size="lg" class="my-4" /> + + <gl-alert + v-if="autocompleteError" + class="gl-text-body gl-mt-2" + :dismissible="false" + variant="danger" + > + {{ $options.i18n.AUTOCOMPLETE_ERROR_MESSAGE }} + </gl-alert> + </div> +</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 new file mode 100644 index 00000000000..239c61fd750 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_items.vue @@ -0,0 +1,38 @@ +<script> +import { GlDisclosureDropdownGroup } from '@gitlab/ui'; +import { mapState, mapGetters } from 'vuex'; +import { ALL_GITLAB } from '~/vue_shared/global_search/constants'; + +export default { + name: 'GlobalSearchDefaultItems', + i18n: { + ALL_GITLAB, + }, + components: { + GlDisclosureDropdownGroup, + }, + computed: { + ...mapState(['searchContext']), + ...mapGetters(['defaultSearchOptions']), + sectionHeader() { + return ( + this.searchContext?.project?.name || + this.searchContext?.group?.name || + this.$options.i18n.ALL_GITLAB + ); + }, + defaultItemsGroup() { + return { + name: this.sectionHeader, + items: this.defaultSearchOptions, + }; + }, + }, +}; +</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> +</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 new file mode 100644 index 00000000000..76600f829f6 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_scoped_items.vue @@ -0,0 +1,54 @@ +<script> +import { GlIcon, GlToken, GlDisclosureDropdownGroup } from '@gitlab/ui'; +import { mapState, mapGetters } from 'vuex'; +import { s__, sprintf } from '~/locale'; +import { truncate } from '~/lib/utils/text_utility'; +import { SCOPE_TOKEN_MAX_LENGTH } from '../constants'; + +export default { + name: 'GlobalSearchScopedItems', + components: { + GlIcon, + GlToken, + GlDisclosureDropdownGroup, + }, + computed: { + ...mapState(['search']), + ...mapGetters(['scopedSearchGroup']), + }, + methods: { + titleLabel(item) { + return sprintf(s__('GlobalSearch|in %{scope}'), { + search: this.search, + scope: item.scope || item.description, + }); + }, + getTruncatedScope(scope) { + return truncate(scope, SCOPE_TOKEN_MAX_LENGTH); + }, + }, +}; +</script> + +<template> + <div> + <ul class="gl-m-0 gl-p-0 gl-pb-2 gl-list-style-none"> + <gl-disclosure-dropdown-group :group="scopedSearchGroup" bordered class="gl-mt-0!"> + <template #list-item="{ item }"> + <span + class="gl-display-flex gl-align-items-center gl-line-height-24 gl-flex-direction-row gl-w-full" + > + <gl-icon name="search" class="gl-flex-shrink-0 gl-mr-2 gl-pt-2 gl-mt-n2" /> + <span class="gl-flex-grow-1"> + <gl-token class="gl-flex-shrink-0 gl-white-space-nowrap gl-float-right" view-only> + <gl-icon v-if="item.icon" :name="item.icon" class="gl-mr-2" /> + <span>{{ getTruncatedScope(titleLabel(item)) }}</span> + </gl-token> + {{ search }} + </span> + </span> + </template> + </gl-disclosure-dropdown-group> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/constants.js b/app/assets/javascripts/super_sidebar/components/global_search/constants.js new file mode 100644 index 00000000000..cb267df6122 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/constants.js @@ -0,0 +1,28 @@ +export const ICON_PROJECT = 'project'; + +export const ICON_GROUP = 'group'; + +export const ICON_SUBGROUP = 'subgroup'; + +export const LARGE_AVATAR_PX = 32; + +export const SMALL_AVATAR_PX = 16; + +export const SEARCH_SHORTCUTS_MIN_CHARACTERS = 2; + +export const SEARCH_INPUT_DESCRIPTION = 'search-input-description'; + +export const SEARCH_RESULTS_DESCRIPTION = 'search-results-description'; + +export const SCOPE_TOKEN_MAX_LENGTH = 36; + +export const INPUT_FIELD_PADDING = 84; + +export const IS_SEARCHING = 'is-searching'; + +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_RESULTS_ITEM_SELECTOR = '.gl-new-dropdown-item'; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/actions.js b/app/assets/javascripts/super_sidebar/components/global_search/store/actions.js new file mode 100644 index 00000000000..a0f9e594506 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/store/actions.js @@ -0,0 +1,45 @@ +import { omitBy, isNil } from 'lodash'; +import { objectToQuery } from '~/lib/utils/url_utility'; +import axios from '~/lib/utils/axios_utils'; +import { FETCH_TYPES } from '../constants'; +import * as types from './mutation_types'; + +export const autocompleteQuery = ({ state, fetchType }) => { + const query = omitBy( + { + term: state.search, + project_id: state.searchContext?.project?.id, + project_ref: state.searchContext?.ref, + filter: fetchType, + }, + isNil, + ); + + return `${state.autocompletePath}?${objectToQuery(query)}`; +}; + +const doFetch = ({ commit, state, fetchType }) => { + return axios + .get(autocompleteQuery({ state, fetchType })) + .then(({ data }) => { + commit(types.RECEIVE_AUTOCOMPLETE_SUCCESS, data); + }) + .catch(() => { + commit(types.RECEIVE_AUTOCOMPLETE_ERROR); + }); +}; + +export const fetchAutocompleteOptions = ({ commit, state }) => { + commit(types.REQUEST_AUTOCOMPLETE); + const promises = FETCH_TYPES.map((fetchType) => doFetch({ commit, state, fetchType })); + + return Promise.all(promises); +}; + +export const clearAutocomplete = ({ commit }) => { + commit(types.CLEAR_AUTOCOMPLETE); +}; + +export const setSearch = ({ commit }, value) => { + commit(types.SET_SEARCH, value); +}; 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 new file mode 100644 index 00000000000..4a42f416206 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/store/getters.js @@ -0,0 +1,222 @@ +import { omitBy, isNil } from 'lodash'; +import { objectToQuery } from '~/lib/utils/url_utility'; +import { + MSG_ISSUES_ASSIGNED_TO_ME, + MSG_ISSUES_IVE_CREATED, + MSG_MR_ASSIGNED_TO_ME, + MSG_MR_IM_REVIEWER, + MSG_MR_IVE_CREATED, + MSG_IN_ALL_GITLAB, + PROJECTS_CATEGORY, + GROUPS_CATEGORY, + SEARCH_RESULTS_ORDER, +} from '~/vue_shared/global_search/constants'; +import { getFormattedItem } from '../utils'; + +import { + ICON_GROUP, + ICON_SUBGROUP, + ICON_PROJECT, + SEARCH_SHORTCUTS_MIN_CHARACTERS, +} from '../constants'; + +export const searchQuery = (state) => { + const query = omitBy( + { + search: state.search, + nav_source: 'navbar', + project_id: state.searchContext?.project?.id, + group_id: state.searchContext?.group?.id, + scope: state.searchContext?.scope, + snippets: state.searchContext?.for_snippets ? true : null, + search_code: state.searchContext?.code_search ? true : null, + repository_ref: state.searchContext?.ref, + }, + isNil, + ); + + return `${state.searchPath}?${objectToQuery(query)}`; +}; + +export const scopedIssuesPath = (state) => { + if (state.searchContext?.project?.id && !state.searchContext?.project_metadata?.issues_path) { + return false; + } + + return ( + state.searchContext?.project_metadata?.issues_path || + state.searchContext?.group_metadata?.issues_path || + state.issuesPath + ); +}; + +export const scopedMRPath = (state) => { + return ( + state.searchContext?.project_metadata?.mr_path || + state.searchContext?.group_metadata?.mr_path || + state.mrPath + ); +}; + +export const defaultSearchOptions = (state, getters) => { + const userName = gon.current_username; + + const issues = [ + { + text: MSG_ISSUES_ASSIGNED_TO_ME, + href: `${getters.scopedIssuesPath}/?assignee_username=${userName}`, + }, + { + text: MSG_ISSUES_IVE_CREATED, + href: `${getters.scopedIssuesPath}/?author_username=${userName}`, + }, + ]; + + const mergeRequests = [ + { + text: MSG_MR_ASSIGNED_TO_ME, + href: `${getters.scopedMRPath}/?assignee_username=${userName}`, + }, + { + text: MSG_MR_IM_REVIEWER, + href: `${getters.scopedMRPath}/?reviewer_username=${userName}`, + }, + { + text: MSG_MR_IVE_CREATED, + href: `${getters.scopedMRPath}/?author_username=${userName}`, + }, + ]; + return [...(getters.scopedIssuesPath ? issues : []), ...mergeRequests]; +}; + +export const projectUrl = (state) => { + const query = omitBy( + { + search: state.search, + nav_source: 'navbar', + project_id: state.searchContext?.project?.id, + group_id: state.searchContext?.group?.id, + scope: state.searchContext?.scope, + snippets: state.searchContext?.for_snippets ? true : null, + search_code: state.searchContext?.code_search ? true : null, + repository_ref: state.searchContext?.ref, + }, + isNil, + ); + + return `${state.searchPath}?${objectToQuery(query)}`; +}; + +export const groupUrl = (state) => { + const query = omitBy( + { + search: state.search, + nav_source: 'navbar', + group_id: state.searchContext?.group?.id, + scope: state.searchContext?.scope, + snippets: state.searchContext?.for_snippets ? true : null, + search_code: state.searchContext?.code_search ? true : null, + repository_ref: state.searchContext?.ref, + }, + isNil, + ); + + return `${state.searchPath}?${objectToQuery(query)}`; +}; + +export const allUrl = (state) => { + const query = omitBy( + { + search: state.search, + nav_source: 'navbar', + scope: state.searchContext?.scope, + snippets: state.searchContext?.for_snippets ? true : null, + search_code: state.searchContext?.code_search ? true : null, + repository_ref: state.searchContext?.ref, + }, + isNil, + ); + + return `${state.searchPath}?${objectToQuery(query)}`; +}; + +export const scopedSearchOptions = (state, getters) => { + const items = []; + + if (state.searchContext?.project) { + items.push({ + text: 'scoped-in-project', + scope: state.searchContext.project?.name || '', + scopeCategory: PROJECTS_CATEGORY, + icon: ICON_PROJECT, + href: getters.projectUrl, + }); + } + + if (state.searchContext?.group) { + items.push({ + text: 'scoped-in-group', + scope: state.searchContext.group?.name || '', + scopeCategory: GROUPS_CATEGORY, + icon: state.searchContext.group?.full_name?.includes('/') ? ICON_SUBGROUP : ICON_GROUP, + href: getters.groupUrl, + }); + } + + items.push({ + text: 'scoped-in-all', + description: MSG_IN_ALL_GITLAB, + href: getters.allUrl, + }); + + return items; +}; + +export const scopedSearchGroup = (state, getters) => { + const items = getters.scopedSearchOptions?.length ? getters.scopedSearchOptions.slice(1) : []; + return { items }; +}; + +export const autocompleteGroupedSearchOptions = (state) => { + const groupedOptions = {}; + const results = []; + + state.autocompleteOptions.forEach((item) => { + const group = groupedOptions[item.category]; + const formattedItem = getFormattedItem(item, state.searchContext); + + if (group) { + group.items.push(formattedItem); + } else { + groupedOptions[item.category] = { + name: formattedItem.category, + items: [formattedItem], + }; + + results.push(groupedOptions[formattedItem.category]); + } + }); + + return results.sort( + (a, b) => SEARCH_RESULTS_ORDER.indexOf(a.name) - SEARCH_RESULTS_ORDER.indexOf(b.name), + ); +}; + +export const searchOptions = (state, getters) => { + if (!state.search) { + return getters.defaultSearchOptions; + } + + const sortedAutocompleteOptions = Object.values(getters.autocompleteGroupedSearchOptions).reduce( + (items, group) => { + return [...items, ...group.items]; + }, + [], + ); + + if (state.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS) { + return sortedAutocompleteOptions; + } + + return (getters.scopedSearchOptions ?? []).concat(sortedAutocompleteOptions); +}; 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 new file mode 100644 index 00000000000..b83433c5b49 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/store/index.js @@ -0,0 +1,25 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import createState from './state'; + +Vue.use(Vuex); + +export const getStoreConfig = ({ + searchPath, + issuesPath, + mrPath, + autocompletePath, + searchContext, + search, +}) => ({ + actions, + getters, + mutations, + state: createState({ searchPath, issuesPath, mrPath, autocompletePath, searchContext, search }), +}); + +const createStore = (config) => new Vuex.Store(getStoreConfig(config)); +export default createStore; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js b/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js new file mode 100644 index 00000000000..d7d9ebecd16 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/store/mutation_types.js @@ -0,0 +1,5 @@ +export const REQUEST_AUTOCOMPLETE = 'REQUEST_AUTOCOMPLETE'; +export const RECEIVE_AUTOCOMPLETE_SUCCESS = 'RECEIVE_AUTOCOMPLETE_SUCCESS'; +export const RECEIVE_AUTOCOMPLETE_ERROR = 'RECEIVE_AUTOCOMPLETE_ERROR'; +export const CLEAR_AUTOCOMPLETE = 'CLEAR_AUTOCOMPLETE'; +export const SET_SEARCH = 'SET_SEARCH'; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js b/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js new file mode 100644 index 00000000000..9936c3f59d8 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/store/mutations.js @@ -0,0 +1,26 @@ +import * as types from './mutation_types'; + +export default { + [types.REQUEST_AUTOCOMPLETE](state) { + state.loading = true; + state.autocompleteOptions = []; + state.autocompleteError = false; + }, + [types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, data) { + state.loading = false; + state.autocompleteOptions = [...state.autocompleteOptions].concat(data); + state.autocompleteError = false; + }, + [types.RECEIVE_AUTOCOMPLETE_ERROR](state) { + state.loading = false; + state.autocompleteOptions = []; + state.autocompleteError = true; + }, + [types.CLEAR_AUTOCOMPLETE](state) { + state.autocompleteOptions = []; + state.autocompleteError = false; + }, + [types.SET_SEARCH](state, value) { + state.search = value; + }, +}; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/store/state.js b/app/assets/javascripts/super_sidebar/components/global_search/store/state.js new file mode 100644 index 00000000000..bebdbc7b92e --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/store/state.js @@ -0,0 +1,19 @@ +const createState = ({ + searchPath, + issuesPath, + mrPath, + autocompletePath, + searchContext, + search, +}) => ({ + searchPath, + issuesPath, + mrPath, + autocompletePath, + searchContext, + search, + autocompleteOptions: [], + autocompleteError: false, + loading: false, +}); +export default createState; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/utils.js b/app/assets/javascripts/super_sidebar/components/global_search/utils.js new file mode 100644 index 00000000000..11d1fa1ab95 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/global_search/utils.js @@ -0,0 +1,81 @@ +import { pickBy } from 'lodash'; +import { truncateNamespace } from '~/lib/utils/text_utility'; +import { + GROUPS_CATEGORY, + PROJECTS_CATEGORY, + MERGE_REQUEST_CATEGORY, + ISSUES_CATEGORY, + RECENT_EPICS_CATEGORY, +} from '~/vue_shared/global_search/constants'; +import { LARGE_AVATAR_PX, SMALL_AVATAR_PX } from './constants'; + +const getTruncatedNamespace = (string) => { + if (string.split(' / ').length > 2) { + return truncateNamespace(string); + } + + return string; +}; +const getAvatarSize = (category) => { + if (category === GROUPS_CATEGORY || category === PROJECTS_CATEGORY) { + return LARGE_AVATAR_PX; + } + + return SMALL_AVATAR_PX; +}; + +const getEntityId = (item, searchContext) => { + switch (item.category) { + case GROUPS_CATEGORY: + case RECENT_EPICS_CATEGORY: + return item.group_id || item.id || searchContext?.group?.id; + case PROJECTS_CATEGORY: + case ISSUES_CATEGORY: + case MERGE_REQUEST_CATEGORY: + return item.project_id || item.id || searchContext?.project?.id; + default: + return item.id; + } +}; +const getEntityName = (item, searchContext) => { + switch (item.category) { + case GROUPS_CATEGORY: + case RECENT_EPICS_CATEGORY: + return item.group_name || item.value || item.label || searchContext?.group?.name; + case PROJECTS_CATEGORY: + case ISSUES_CATEGORY: + case MERGE_REQUEST_CATEGORY: + return item.project_name || item.value || item.label || searchContext?.project?.name; + default: + return item.label; + } +}; + +export const getFormattedItem = (item, searchContext) => { + const { id, category, value, label, url: href, avatar_url } = item; + let namespace; + const text = value || label; + if (value) { + namespace = getTruncatedNamespace(label); + } + const avatarSize = getAvatarSize(category); + const entityId = getEntityId(item, searchContext); + const entityName = getEntityName(item, searchContext); + + return pickBy( + { + id, + category, + value, + label, + text, + href, + avatar_url, + avatar_size: avatarSize, + namespace, + entity_id: entityId, + entity_name: entityName, + }, + (val) => val !== undefined, + ); +}; diff --git a/app/assets/javascripts/super_sidebar/components/groups_list.vue b/app/assets/javascripts/super_sidebar/components/groups_list.vue new file mode 100644 index 00000000000..4fa15f1cd76 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/groups_list.vue @@ -0,0 +1,81 @@ +<script> +import { s__ } from '~/locale'; +import { MAX_FREQUENT_GROUPS_COUNT } from '../constants'; +import FrequentItemsList from './frequent_items_list.vue'; +import SearchResults from './search_results.vue'; +import NavItem from './nav_item.vue'; + +export default { + MAX_FREQUENT_GROUPS_COUNT, + components: { + FrequentItemsList, + SearchResults, + NavItem, + }, + props: { + username: { + type: String, + required: true, + }, + viewAllLink: { + type: String, + required: true, + }, + isSearch: { + type: Boolean, + required: false, + default: false, + }, + searchResults: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + storageKey() { + return `${this.username}/frequent-groups`; + }, + viewAllProps() { + return { + item: { + link: this.viewAllLink, + title: s__('Navigation|View all your groups'), + icon: 'group', + }, + linkClasses: { 'dashboard-shortcuts-groups': true }, + }; + }, + }, + i18n: { + title: s__('Navigation|Frequently visited groups'), + searchTitle: s__('Navigation|Groups'), + pristineText: s__('Navigation|Groups you visit often will appear here.'), + noResultsText: s__('Navigation|No group matches found'), + }, +}; +</script> + +<template> + <search-results + v-if="isSearch" + :title="$options.i18n.searchTitle" + :no-results-text="$options.i18n.noResultsText" + :search-results="searchResults" + > + <template #view-all-items> + <nav-item v-bind="viewAllProps" /> + </template> + </search-results> + <frequent-items-list + v-else + :title="$options.i18n.title" + :storage-key="storageKey" + :max-items="$options.MAX_FREQUENT_GROUPS_COUNT" + :pristine-text="$options.i18n.pristineText" + > + <template #view-all-items> + <nav-item v-bind="viewAllProps" /> + </template> + </frequent-items-list> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/help_center.vue b/app/assets/javascripts/super_sidebar/components/help_center.vue index 8e7c7efa631..1fffbb05d03 100644 --- a/app/assets/javascripts/super_sidebar/components/help_center.vue +++ b/app/assets/javascripts/super_sidebar/components/help_center.vue @@ -1,19 +1,32 @@ <script> -import { GlBadge, GlButton, GlDisclosureDropdown, GlDisclosureDropdownGroup } from '@gitlab/ui'; +import { + GlBadge, + GlButton, + GlIcon, + GlDisclosureDropdown, + GlDisclosureDropdownGroup, +} from '@gitlab/ui'; import GitlabVersionCheckBadge from '~/gitlab_version_check/components/gitlab_version_check_badge.vue'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility'; -import { __ } from '~/locale'; +import { DOMAIN, PROMO_URL } from 'jh_else_ce/lib/utils/url_utility'; +import { __, s__ } from '~/locale'; import { STORAGE_KEY } from '~/whats_new/utils/notification'; +import Tracking from '~/tracking'; +import { DROPDOWN_Y_OFFSET, HELP_MENU_TRACKING_DEFAULTS, helpCenterState } from '../constants'; + +// Left offset required for the dropdown to be aligned with the super sidebar +const DROPDOWN_X_OFFSET = -4; export default { components: { GlBadge, GlButton, + GlIcon, GlDisclosureDropdown, GlDisclosureDropdownGroup, GitlabVersionCheckBadge, }, + mixins: [Tracking.mixin({ property: 'nav_help_menu' })], i18n: { help: __('Help'), support: __('Support'), @@ -25,6 +38,7 @@ export default { shortcuts: __('Keyboard shortcuts'), version: __('Your GitLab version'), whatsnew: __("What's new"), + chat: s__('TanukiBot|Ask GitLab Chat'), }, props: { sidebarData: { @@ -35,6 +49,7 @@ export default { data() { return { showWhatsNewNotification: this.shouldShowWhatsNewNotification(), + helpCenterState, }; }, computed: { @@ -46,28 +61,84 @@ export default { text: this.$options.i18n.version, href: helpPagePath('update/index'), version: `${this.sidebarData.gitlab_version.major}.${this.sidebarData.gitlab_version.minor}`, + extraAttrs: { + ...this.trackingAttrs('version_help_dropdown'), + }, }, ], }, helpLinks: { items: [ - { text: this.$options.i18n.help, href: helpPagePath() }, - { text: this.$options.i18n.support, href: this.sidebarData.support_path }, - { text: this.$options.i18n.docs, href: 'https://docs.gitlab.com' }, - { text: this.$options.i18n.plans, href: `${PROMO_URL}/pricing` }, - { text: this.$options.i18n.forum, href: 'https://forum.gitlab.com/' }, + this.sidebarData.show_tanuki_bot && { + icon: 'tanuki', + text: this.$options.i18n.chat, + action: this.showTanukiBotChat, + extraAttrs: { + ...this.trackingAttrs('tanuki_bot_help_dropdown'), + }, + }, + { + text: this.$options.i18n.help, + href: helpPagePath(), + extraAttrs: { + ...this.trackingAttrs('help'), + }, + }, + { + text: this.$options.i18n.support, + href: this.sidebarData.support_path, + extraAttrs: { + ...this.trackingAttrs('support'), + }, + }, + { + text: this.$options.i18n.docs, + href: `https://docs.${DOMAIN}`, + extraAttrs: { + ...this.trackingAttrs('gitlab_documentation'), + }, + }, + { + text: this.$options.i18n.plans, + href: `${PROMO_URL}/pricing`, + extraAttrs: { + ...this.trackingAttrs('compare_gitlab_plans'), + }, + }, + { + text: this.$options.i18n.forum, + href: `https://forum.${DOMAIN}/`, + extraAttrs: { + ...this.trackingAttrs('community_forum'), + }, + }, { text: this.$options.i18n.contribute, href: helpPagePath('', { anchor: 'contributing-to-gitlab' }), + extraAttrs: { + ...this.trackingAttrs('contribute_to_gitlab'), + }, }, - { text: this.$options.i18n.feedback, href: 'https://about.gitlab.com/submit-feedback' }, - ], + { + text: this.$options.i18n.feedback, + href: `${PROMO_URL}/submit-feedback`, + extraAttrs: { + ...this.trackingAttrs('submit_feedback'), + }, + }, + ].filter(Boolean), }, helpActions: { items: [ { text: this.$options.i18n.shortcuts, action: this.showKeyboardShortcuts, + extraAttrs: { + class: 'js-shortcuts-modal-trigger', + 'data-track-action': 'click_button', + 'data-track-label': 'keyboard_shortcuts_help', + 'data-track-property': HELP_MENU_TRACKING_DEFAULTS['data-track-property'], + }, shortcut: '?', }, this.sidebarData.display_whats_new && { @@ -76,6 +147,11 @@ export default { count: this.showWhatsNewNotification && this.sidebarData.whats_new_most_recent_release_items_count, + extraAttrs: { + 'data-track-action': 'click_button', + 'data-track-label': 'whats_new', + 'data-track-property': HELP_MENU_TRACKING_DEFAULTS['data-track-property'], + }, }, ].filter(Boolean), }, @@ -96,15 +172,14 @@ export default { return true; }, - handleAction({ action }) { - if (action) { - action(); - } + showKeyboardShortcuts() { + this.$refs.dropdown.close(); }, - showKeyboardShortcuts() { + showTanukiBotChat() { this.$refs.dropdown.close(); - window?.toggleShortcutsHelp(); + + this.helpCenterState.showTanukiBotChatDrawer = true; }, async showWhatsNew() { @@ -122,15 +197,43 @@ export default { this.toggleWhatsNewDrawer(); } }, + + trackingAttrs(label) { + return { + ...HELP_MENU_TRACKING_DEFAULTS, + 'data-track-label': label, + }; + }, + + trackDropdownToggle(show) { + this.track('click_toggle', { + label: show ? 'show_help_dropdown' : 'hide_help_dropdown', + }); + }, + }, + popperOptions: { + modifiers: [ + { + name: 'offset', + options: { + offset: [DROPDOWN_X_OFFSET, DROPDOWN_Y_OFFSET], + }, + }, + ], }, }; </script> <template> - <gl-disclosure-dropdown ref="dropdown"> + <gl-disclosure-dropdown + ref="dropdown" + :popper-options="$options.popperOptions" + @shown="trackDropdownToggle(true)" + @hidden="trackDropdownToggle(false)" + > <template #toggle> <gl-button category="tertiary" icon="question-o" class="btn-with-notification"> - <span v-if="showWhatsNewNotification" class="notification"></span> + <span v-if="showWhatsNewNotification" class="notification-dot-info"></span> {{ $options.i18n.help }} </gl-button> </template> @@ -140,11 +243,7 @@ export default { :group="itemGroups.versionCheck" > <template #list-item="{ item }"> - <a - :href="item.href" - tabindex="-1" - class="gl-display-flex gl-flex-direction-column gl-line-height-24 gl-text-gray-900 gl-hover-text-gray-900 gl-hover-text-decoration-none" - > + <span class="gl-display-flex gl-flex-direction-column gl-line-height-24"> <span class="gl-font-sm gl-font-weight-bold"> {{ item.text }} <gl-emoji data-name="rocket" /> @@ -153,25 +252,31 @@ export default { <span class="gl-mr-2">{{ item.version }}</span> <gitlab-version-check-badge v-if="updateSeverity" :status="updateSeverity" size="sm" /> </span> - </a> + </span> </template> </gl-disclosure-dropdown-group> <gl-disclosure-dropdown-group :group="itemGroups.helpLinks" :bordered="sidebarData.show_version_check" - /> + > + <template #list-item="{ item }"> + <span class="gl-display-flex gl-justify-content-space-between gl-align-items-center"> + {{ item.text }} + <gl-icon v-if="item.icon" :name="item.icon" class="gl-text-orange-500" /> + </span> + </template> + </gl-disclosure-dropdown-group> - <gl-disclosure-dropdown-group :group="itemGroups.helpActions" bordered @action="handleAction"> + <gl-disclosure-dropdown-group :group="itemGroups.helpActions" bordered> <template #list-item="{ item }"> - <button - tabindex="-1" - class="gl-bg-transparent gl-w-full gl-border-none gl-display-flex gl-justify-content-space-between gl-p-0 gl-text-gray-900" + <span + class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-my-n1" > {{ item.text }} <gl-badge v-if="item.count" pill size="sm" variant="info">{{ item.count }}</gl-badge> <kbd v-else-if="item.shortcut" class="flat">?</kbd> - </button> + </span> </template> </gl-disclosure-dropdown-group> </gl-disclosure-dropdown> diff --git a/app/assets/javascripts/super_sidebar/components/items_list.vue b/app/assets/javascripts/super_sidebar/components/items_list.vue new file mode 100644 index 00000000000..ef27251dc6c --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/items_list.vue @@ -0,0 +1,58 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; +import NavItem from './nav_item.vue'; + +export default { + components: { + GlButton, + ProjectAvatar, + NavItem, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + items: { + type: Array, + required: false, + default: () => [], + }, + }, +}; +</script> + +<template> + <ul class="gl-p-0 gl-list-style-none"> + <nav-item + v-for="item in items" + :key="item.id" + :item="item" + :link-classes="{ 'gl-py-2!': true }" + > + <template #icon> + <project-avatar + :project-id="item.id" + :project-name="item.title" + :project-avatar-url="item.avatar" + :size="24" + aria-hidden="true" + /> + </template> + <template #actions> + <gl-button + v-gl-tooltip.right.viewport + size="small" + category="tertiary" + icon="dash" + :aria-label="__('Remove')" + :title="__('Remove')" + class="gl-align-self-center gl-p-1! gl-absolute gl-right-4" + data-testid="item-remove" + @click.stop.prevent="$emit('remove-item', item)" + /> + </template> + </nav-item> + <slot name="view-all-items"></slot> + </ul> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/menu_section.vue b/app/assets/javascripts/super_sidebar/components/menu_section.vue new file mode 100644 index 00000000000..93c249dffeb --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/menu_section.vue @@ -0,0 +1,122 @@ +<script> +import { kebabCase } from 'lodash'; +import { GlCollapse, GlIcon } from '@gitlab/ui'; +import NavItem from './nav_item.vue'; + +export default { + name: 'MenuSection', + components: { + GlCollapse, + GlIcon, + NavItem, + }, + props: { + item: { + type: Object, + required: true, + }, + expanded: { + type: Boolean, + required: false, + default: false, + }, + separated: { + type: Boolean, + required: false, + default: false, + }, + tag: { + type: String, + required: false, + default: 'div', + }, + }, + data() { + return { + isExpanded: Boolean(this.expanded || this.item.is_active), + }; + }, + computed: { + buttonProps() { + return { + 'aria-controls': this.itemId, + 'aria-expanded': String(this.isExpanded), + 'data-qa-menu-item': this.item.title, + }; + }, + collapseIcon() { + return this.isExpanded ? 'chevron-up' : 'chevron-down'; + }, + computedLinkClasses() { + return { + 'gl-bg-t-gray-a-08': this.isActive, + }; + }, + isActive() { + return !this.isExpanded && this.item.is_active; + }, + itemId() { + return kebabCase(this.item.title); + }, + }, + watch: { + isExpanded(newIsExpanded) { + this.$emit('collapse-toggle', newIsExpanded); + }, + }, +}; +</script> + +<template> + <component :is="tag"> + <hr v-if="separated" aria-hidden="true" class="gl-mx-4 gl-my-2" /> + <button + class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-mb-1 gl-py-3 gl-px-0 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-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" + > + <span + :class="[isActive ? '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" + ></span> + <span class="gl-flex-shrink-0 gl-w-6 gl-mx-3"> + <slot name="icon"> + <gl-icon v-if="item.icon" :name="item.icon" class="gl-ml-2 item-icon" /> + </slot> + </span> + + <span class="gl-pr-3 gl-text-gray-900 gl-truncate-end"> + {{ item.title }} + </span> + + <span class="gl-flex-grow-1 gl-text-right gl-mr-3 gl-text-gray-400"> + <gl-icon :name="collapseIcon" /> + </span> + </button> + + <gl-collapse + :id="itemId" + v-model="isExpanded" + :aria-label="item.title" + class="gl-list-style-none gl-p-0 gl-m-0 gl-transition-duration-medium gl-transition-timing-function-ease" + data-qa-selector="menu_section" + :data-qa-section-name="item.title" + tag="ul" + > + <slot> + <nav-item + v-for="subItem of item.items" + :key="`${item.title}-${subItem.title}`" + :item="subItem" + @pin-add="(itemId) => $emit('pin-add', itemId)" + @pin-remove="(itemId) => $emit('pin-remove', itemId)" + /> + </slot> + </gl-collapse> + </component> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue b/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue index edc13e305cf..260c3906b93 100644 --- a/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue @@ -1,5 +1,6 @@ <script> import { GlBadge, GlDisclosureDropdown } from '@gitlab/ui'; +import { userCounts } from '~/super_sidebar/user_counts_manager'; export default { components: { @@ -13,28 +14,28 @@ export default { }, }, methods: { - navigate() { - this.$refs.link.click(); + getCount(item) { + return userCounts[item.userCount] ?? item.count ?? 0; }, }, }; </script> <template> - <gl-disclosure-dropdown :items="items" placement="center" @action="navigate"> + <gl-disclosure-dropdown + :items="items" + placement="center" + @shown="$emit('shown')" + @hidden="$emit('hidden')" + > <template #toggle> <slot></slot> </template> <template #list-item="{ item }"> - <a - ref="link" - class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-hover-text-gray-900 gl-hover-text-decoration-none gl-text-gray-900" - :href="item.href" - tabindex="-1" - > + <span class="gl-display-flex gl-align-items-center gl-justify-content-space-between"> {{ item.text }} - <gl-badge pill size="sm" variant="neutral">{{ item.count || 0 }}</gl-badge> - </a> + <gl-badge pill size="sm" variant="neutral">{{ getCount(item) }}</gl-badge> + </span> </template> </gl-disclosure-dropdown> </template> diff --git a/app/assets/javascripts/super_sidebar/components/nav_item.vue b/app/assets/javascripts/super_sidebar/components/nav_item.vue index 4fd6918fd6f..ec1c4069b1a 100644 --- a/app/assets/javascripts/super_sidebar/components/nav_item.vue +++ b/app/assets/javascripts/super_sidebar/components/nav_item.vue @@ -1,37 +1,178 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlButton, GlIcon, GlBadge, GlTooltipDirective } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { + CLICK_MENU_ITEM_ACTION, + CLICK_PINNED_MENU_ITEM_ACTION, + TRACKING_UNKNOWN_ID, + TRACKING_UNKNOWN_PANEL, +} from '~/super_sidebar/constants'; +import NavItemLink from './nav_item_link.vue'; +import NavItemRouterLink from './nav_item_router_link.vue'; export default { + i18n: { + pinItem: s__('Navigation|Pin item'), + unpinItem: s__('Navigation|Unpin item'), + }, name: 'NavItem', components: { + GlButton, GlIcon, + GlBadge, + NavItemLink, + NavItemRouterLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: { + pinnedItemIds: { default: { ids: [] } }, + panelSupportsPins: { default: false }, + panelType: { default: '' }, }, props: { + isInPinnedSection: { + type: Boolean, + required: false, + default: false, + }, + isStatic: { + type: Boolean, + required: false, + default: false, + }, item: { type: Object, required: true, }, + linkClasses: { + type: Object, + required: false, + default: () => ({}), + }, + }, + computed: { + pillData() { + return this.item.pill_count; + }, + hasPill() { + return ( + Number.isFinite(this.pillData) || + (typeof this.pillData === 'string' && this.pillData !== '') + ); + }, + isPinnable() { + return this.panelSupportsPins && !this.isStatic; + }, + isPinned() { + return this.pinnedItemIds.ids.includes(this.item.id); + }, + trackingProps() { + // Set extra event data to debug missing IDs / Panel Types + const extraData = + !this.item.id || !this.panelType + ? { 'data-track-extra': JSON.stringify({ title: this.item.title }) } + : {}; + + return { + 'data-track-action': this.isInPinnedSection + ? CLICK_PINNED_MENU_ITEM_ACTION + : CLICK_MENU_ITEM_ACTION, + 'data-track-label': this.item.id ?? TRACKING_UNKNOWN_ID, + 'data-track-property': this.panelType + ? `nav_panel_${this.panelType}` + : TRACKING_UNKNOWN_PANEL, + ...extraData, + }; + }, + linkProps() { + return { + ...this.$attrs, + ...this.trackingProps, + item: this.item, + 'data-qa-submenu-item': this.item.title, + 'data-method': this.item.data_method ?? null, + }; + }, + computedLinkClasses() { + return { + 'gl-py-2': this.isPinnable, + 'gl-py-3': !this.isPinnable, + [this.item.link_classes]: this.item.link_classes, + ...this.linkClasses, + }; + }, + navItemLinkComponent() { + return this.item.to ? NavItemRouterLink : NavItemLink; + }, }, }; </script> <template> <li> - <a - :href="item.link" - class="gl-display-flex gl-pl-3 gl-py-3 gl-line-height-normal gl-text-black-normal gl-hover-bg-t-gray-a-08" + <component + :is="navItemLinkComponent" + #default="{ isActive }" + v-bind="linkProps" + class="nav-item-link gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-mb-1 gl-px-0 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-focus--focus" + :class="computedLinkClasses" + data-qa-selector="nav_item_link" + data-testid="nav-item-link" > - <div class="gl-mr-3"> + <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" + 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-mx-3"> <slot name="icon"> - <gl-icon v-if="item.icon" :name="item.icon" /> + <gl-icon v-if="item.icon" :name="item.icon" class="gl-ml-2 item-icon" /> + <gl-icon + v-else-if="isInPinnedSection" + name="grip" + class="gl-text-gray-400 gl-ml-2 draggable-icon" + /> </slot> </div> - <div class="gl-pr-3"> + <div class="gl-pr-8 gl-text-gray-900 gl-truncate-end"> {{ item.title }} - <div v-if="item.subtitle" class="gl-font-sm gl-text-gray-500 gl-mt-1"> + <div v-if="item.subtitle" class="gl-font-sm gl-text-gray-500 gl-truncate-end"> {{ item.subtitle }} </div> </div> - </a> + <slot name="actions"></slot> + <span v-if="hasPill || isPinnable" class="gl-flex-grow-1 gl-text-right gl-mr-3 gl-relative"> + <gl-badge + v-if="hasPill" + size="sm" + variant="neutral" + :class="{ 'nav-item-badge gl-absolute gl-right-0 gl-top-2': isPinnable }" + > + {{ pillData }} + </gl-badge> + <gl-button + v-if="isPinnable && !isPinned" + v-gl-tooltip.right.viewport="$options.i18n.pinItem" + size="small" + category="tertiary" + icon="thumbtack" + :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" + size="small" + category="tertiary" + :aria-label="$options.i18n.unpinItem" + icon="thumbtack-solid" + @click.prevent="$emit('pin-remove', item.id)" + /> + </span> + </component> </li> </template> diff --git a/app/assets/javascripts/super_sidebar/components/nav_item_link.vue b/app/assets/javascripts/super_sidebar/components/nav_item_link.vue new file mode 100644 index 00000000000..8358e96db94 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/nav_item_link.vue @@ -0,0 +1,35 @@ +<script> +import { NAV_ITEM_LINK_ACTIVE_CLASS } from '../constants'; +import { ariaCurrent } from '../utils'; + +export default { + props: { + item: { + type: Object, + required: true, + }, + }, + computed: { + isActive() { + return this.item.is_active; + }, + linkProps() { + return { + href: this.item.link, + 'aria-current': ariaCurrent(this.isActive), + }; + }, + computedLinkClasses() { + return { + [NAV_ITEM_LINK_ACTIVE_CLASS]: this.isActive, + }; + }, + }, +}; +</script> + +<template> + <a v-bind="linkProps" :class="computedLinkClasses"> + <slot :is-active="isActive"></slot> + </a> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/nav_item_router_link.vue b/app/assets/javascripts/super_sidebar/components/nav_item_router_link.vue new file mode 100644 index 00000000000..78aca24d9a6 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/nav_item_router_link.vue @@ -0,0 +1,37 @@ +<script> +import { NAV_ITEM_LINK_ACTIVE_CLASS } from '../constants'; +import { ariaCurrent } from '../utils'; + +export default { + NAV_ITEM_LINK_ACTIVE_CLASS, + props: { + item: { + type: Object, + required: true, + }, + }, + computed: { + linkProps() { + return { + to: this.item.to, + }; + }, + }, + methods: { + ariaCurrent, + }, +}; +</script> + +<template> + <router-link + #default="{ href, navigate, isActive }" + v-bind="linkProps" + :active-class="$options.NAV_ITEM_LINK_ACTIVE_CLASS" + custom + > + <a :href="href" :aria-current="ariaCurrent(isActive)" @click="navigate"> + <slot :is-active="isActive"></slot> + </a> + </router-link> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/pinned_section.vue b/app/assets/javascripts/super_sidebar/components/pinned_section.vue new file mode 100644 index 00000000000..ccd739c8bb1 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/pinned_section.vue @@ -0,0 +1,101 @@ +<script> +import Draggable from 'vuedraggable'; +import { s__ } from '~/locale'; +import { setCookie, getCookie } from '~/lib/utils/common_utils'; +import { SIDEBAR_PINS_EXPANDED_COOKIE, SIDEBAR_COOKIE_EXPIRATION } from '../constants'; +import MenuSection from './menu_section.vue'; +import NavItem from './nav_item.vue'; + +export default { + i18n: { + pinned: s__('Navigation|Pinned'), + emptyHint: s__('Navigation|Your pinned items appear here.'), + }, + name: 'PinnedSection', + components: { + Draggable, + MenuSection, + NavItem, + }, + props: { + items: { + type: Array, + required: false, + default: () => [], + }, + separated: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + expanded: getCookie(SIDEBAR_PINS_EXPANDED_COOKIE) !== 'false', + draggableItems: this.items, + }; + }, + computed: { + isActive() { + return this.items.some((item) => item.is_active); + }, + sectionItem() { + return { title: this.$options.i18n.pinned, icon: 'thumbtack', is_active: this.isActive }; + }, + itemIds() { + return this.draggableItems.map((item) => item.id); + }, + }, + watch: { + expanded(newExpanded) { + setCookie(SIDEBAR_PINS_EXPANDED_COOKIE, newExpanded, { + expires: SIDEBAR_COOKIE_EXPIRATION, + }); + }, + items(newItems) { + this.draggableItems = newItems; + }, + }, + methods: { + handleDrag(event) { + if (event.oldIndex === event.newIndex) return; + this.$emit( + 'pin-reorder', + this.items[event.oldIndex].id, + this.items[event.newIndex].id, + event.oldIndex < event.newIndex, + ); + }, + }, +}; +</script> + +<template> + <menu-section + :item="sectionItem" + :expanded="expanded" + :separated="separated" + @collapse-toggle="expanded = !expanded" + > + <draggable + v-if="items.length > 0" + v-model="draggableItems" + class="gl-p-0 gl-m-0" + data-testid="pinned-nav-items" + handle=".draggable-icon" + tag="ul" + @end="handleDrag" + > + <nav-item + v-for="item of draggableItems" + :key="item.id" + :item="item" + is-in-pinned-section + @pin-remove="(itemId) => $emit('pin-remove', itemId)" + /> + </draggable> + <li v-else class="gl-text-secondary gl-font-sm gl-py-3" style="margin-left: 2.5rem"> + {{ $options.i18n.emptyHint }} + </li> + </menu-section> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/projects_list.vue b/app/assets/javascripts/super_sidebar/components/projects_list.vue new file mode 100644 index 00000000000..78860e35eb1 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/projects_list.vue @@ -0,0 +1,82 @@ +<script> +import { s__ } from '~/locale'; +import { MAX_FREQUENT_PROJECTS_COUNT } from '../constants'; +import FrequentItemsList from './frequent_items_list.vue'; +import SearchResults from './search_results.vue'; +import NavItem from './nav_item.vue'; + +export default { + MAX_FREQUENT_PROJECTS_COUNT, + components: { + FrequentItemsList, + SearchResults, + NavItem, + }, + props: { + username: { + type: String, + required: true, + }, + viewAllLink: { + type: String, + required: true, + }, + isSearch: { + type: Boolean, + required: false, + default: false, + }, + searchResults: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + storageKey() { + return `${this.username}/frequent-projects`; + }, + viewAllProps() { + return { + item: { + link: this.viewAllLink, + title: s__('Navigation|View all your projects'), + icon: 'project', + }, + linkClasses: { 'dashboard-shortcuts-projects': true }, + }; + }, + }, + i18n: { + title: s__('Navigation|Frequently visited projects'), + searchTitle: s__('Navigation|Projects'), + pristineText: s__('Navigation|Projects you visit often will appear here.'), + noResultsText: s__('Navigation|No project matches found'), + }, +}; +</script> + +<template> + <search-results + v-if="isSearch" + class="gl-border-t-0" + :title="$options.i18n.searchTitle" + :no-results-text="$options.i18n.noResultsText" + :search-results="searchResults" + > + <template #view-all-items> + <nav-item v-bind="viewAllProps" /> + </template> + </search-results> + <frequent-items-list + v-else + :title="$options.i18n.title" + :storage-key="storageKey" + :max-items="$options.MAX_FREQUENT_PROJECTS_COUNT" + :pristine-text="$options.i18n.pristineText" + > + <template #view-all-items> + <nav-item v-bind="viewAllProps" /> + </template> + </frequent-items-list> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/search_results.vue b/app/assets/javascripts/super_sidebar/components/search_results.vue new file mode 100644 index 00000000000..ff933f341af --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/search_results.vue @@ -0,0 +1,99 @@ +<script> +import { GlCollapse, GlCollapseToggleDirective, GlIcon } from '@gitlab/ui'; +import uniqueId from 'lodash/uniqueId'; +import ItemsList from './items_list.vue'; + +export default { + components: { + GlCollapse, + GlIcon, + ItemsList, + }, + directives: { + CollapseToggle: GlCollapseToggleDirective, + }, + props: { + title: { + type: String, + required: true, + }, + noResultsText: { + type: String, + required: true, + }, + searchResults: { + type: Array, + required: false, + default: () => [], + }, + }, + data() { + return { + expanded: true, + }; + }, + computed: { + isEmpty() { + return !this.searchResults.length; + }, + collapseIcon() { + return this.expanded ? 'chevron-up' : 'chevron-down'; + }, + }, + created() { + this.collapseId = uniqueId('expandable-section-'); + }, + buttonClasses: [ + // Reset user agent styles + 'gl-appearance-none', + 'gl-border-0', + 'gl-bg-transparent', + // Text styles + 'gl-text-left', + 'gl-text-transform-uppercase', + 'gl-text-secondary', + 'gl-font-weight-semibold', + 'gl-font-xs', + 'gl-line-height-12', + 'gl-letter-spacing-06em', + // Border + 'gl-border-t', + 'gl-border-gray-50', + // Spacing + 'gl-my-3', + 'gl-pt-2', + // Layout + 'gl-display-flex', + 'gl-justify-content-space-between', + 'gl-align-items-center', + ], +}; +</script> + +<template> + <li class="gl-border-t gl-border-gray-50"> + <button + v-collapse-toggle="collapseId" + :class="$options.buttonClasses" + class="gl-mx-3" + data-testid="search-results-toggle" + > + {{ title }} + <gl-icon :name="collapseIcon" :size="16" /> + </button> + <gl-collapse :id="collapseId" v-model="expanded"> + <div + v-if="isEmpty" + data-testid="empty-text" + class="gl-text-gray-500 gl-font-sm gl-mb-3 gl-mx-4" + > + {{ noResultsText }} + </div> + <items-list :aria-label="title" :items="searchResults"> + <template #view-all-items> + <slot name="view-all-items"></slot> + </template> + </items-list> + </gl-collapse> + </li> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue new file mode 100644 index 00000000000..08af9232107 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue @@ -0,0 +1,178 @@ +<script> +import * as Sentry from '@sentry/browser'; +import axios from '~/lib/utils/axios_utils'; +import { PANELS_WITH_PINS } from '../constants'; +import NavItem from './nav_item.vue'; +import PinnedSection from './pinned_section.vue'; +import MenuSection from './menu_section.vue'; + +export default { + name: 'SidebarMenu', + components: { + MenuSection, + NavItem, + PinnedSection, + }, + + provide() { + return { + pinnedItemIds: this.changedPinnedItemIds, + panelSupportsPins: this.supportsPins, + panelType: this.panelType, + }; + }, + props: { + items: { + type: Array, + required: true, + }, + pinnedItemIds: { + type: Array, + required: false, + default: () => [], + }, + panelType: { + type: String, + required: false, + default: '', + }, + updatePinsUrl: { + type: String, + required: true, + }, + }, + + data() { + return { + // 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 }, + }; + }, + + computed: { + // Returns the list of items that we want to have static at the top. + // Only sidebars that support pins also support a static section. + staticItems() { + if (!this.supportsPins) return []; + return this.items.filter((item) => !item.items || item.items.length === 0); + }, + + // Returns only the items that aren't static at the top and makes sure no + // section shows as active (and expanded) when one of its items is pinned. + nonStaticItems() { + if (!this.supportsPins) return this.items; + + return this.items + .filter((item) => item.items && item.items.length > 0) + .map((item) => { + const hasActivePinnedChild = item.items.some((childItem) => { + return childItem.is_active && this.changedPinnedItemIds.ids.includes(childItem.id); + }); + const showAsActive = item.is_active && !hasActivePinnedChild; + + return { ...item, is_active: showAsActive }; + }); + }, + + // Returns a flat list of all items that are in sections, but not the sections. + // Only items from sections (item.items) can be pinned. + flatPinnableItems() { + return this.nonStaticItems.flatMap((item) => item.items).filter(Boolean); + }, + + pinnedItems() { + return this.changedPinnedItemIds.ids + .map((id) => this.flatPinnableItems.find((item) => item.id === id)) + .filter(Boolean); + }, + supportsPins() { + return PANELS_WITH_PINS.includes(this.panelType); + }, + hasStaticItems() { + return this.staticItems.length > 0; + }, + }, + methods: { + createPin(itemId) { + this.changedPinnedItemIds.ids.push(itemId); + this.updatePins(); + }, + destroyPin(itemId) { + this.changedPinnedItemIds.ids = this.changedPinnedItemIds.ids.filter((id) => id !== itemId); + this.updatePins(); + }, + movePin(fromId, toId, isDownwards) { + const fromIndex = this.changedPinnedItemIds.ids.indexOf(fromId); + this.changedPinnedItemIds.ids.splice(fromIndex, 1); + + let toIndex = this.changedPinnedItemIds.ids.indexOf(toId); + + // If the item was moved downwards, we insert it *after* the item it was dragged on to. + // This matches how vuedraggable previews the change while still dragging. + if (isDownwards) toIndex += 1; + + this.changedPinnedItemIds.ids.splice(toIndex, 0, fromId); + + this.updatePins(); + }, + updatePins() { + axios + .put(this.updatePinsUrl, { + panel: this.panelType, + menu_item_ids: this.changedPinnedItemIds.ids, + }) + .then((response) => { + this.changedPinnedItemIds.ids = response.data; + }) + .catch((e) => { + Sentry.captureException(e); + }); + }, + isSection(navItem) { + return navItem.items?.length; + }, + }, +}; +</script> + +<template> + <nav class="gl-p-2 gl-relative"> + <ul v-if="hasStaticItems" class="gl-p-0 gl-m-0"> + <nav-item v-for="item in staticItems" :key="item.id" :item="item" is-static /> + </ul> + <pinned-section + v-if="supportsPins" + separated + :items="pinnedItems" + @pin-remove="destroyPin" + @pin-reorder="movePin" + /> + <hr + v-if="supportsPins" + aria-hidden="true" + class="gl-my-2 gl-mx-4" + data-testid="main-menu-separator" + /> + <ul class="gl-p-0 gl-list-style-none"> + <template v-for="item in nonStaticItems"> + <menu-section + v-if="isSection(item)" + :key="item.id" + :item="item" + :separated="item.separated" + @pin-add="createPin" + @pin-remove="destroyPin" + /> + <nav-item + v-else + :key="item.id" + :item="item" + tag="li" + @pin-add="createPin" + @pin-remove="destroyPin" + /> + </template> + </ul> + </nav> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue b/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue new file mode 100644 index 00000000000..9d2836e9dfa --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/sidebar_peek_behavior.vue @@ -0,0 +1,122 @@ +<script> +import { getCssClassDimensions } from '~/lib/utils/css_utils'; +import { SUPER_SIDEBAR_PEEK_OPEN_DELAY, SUPER_SIDEBAR_PEEK_CLOSE_DELAY } from '../constants'; + +export const STATE_CLOSED = 'closed'; +export const STATE_WILL_OPEN = 'will-open'; +export const STATE_OPEN = 'open'; +export const STATE_WILL_CLOSE = 'will-close'; + +export default { + name: 'SidebarPeek', + created() { + // Nothing needs to observe these properties, so they are not reactive. + this.state = null; + this.openTimer = null; + this.closeTimer = null; + this.xNearWindowEdge = null; + this.xSidebarEdge = null; + this.xAwayFromSidebar = null; + }, + mounted() { + this.xNearWindowEdge = getCssClassDimensions('gl-w-3').width; + this.xSidebarEdge = getCssClassDimensions('super-sidebar').width; + this.xAwayFromSidebar = 2 * this.xSidebarEdge; + document.addEventListener('mousemove', this.onMouseMove); + document.documentElement.addEventListener('mouseleave', this.onDocumentLeave); + this.changeState(STATE_CLOSED); + }, + beforeDestroy() { + document.removeEventListener('mousemove', this.onMouseMove); + document.documentElement.removeEventListener('mouseleave', this.onDocumentLeave); + this.clearTimers(); + }, + methods: { + /** + * Callback for document-wide mousemove events. + * + * Since mousemove events can fire frequently, it's important for this to + * do as little work as possible. + * + * When mousemove events fire within one of the defined regions, this ends + * up being a no-op. Only when the cursor moves from one region to another + * does this do any work: it sets a non-reactive instance property, maybe + * cancels/starts timers, and emits an event. + * + * @params {MouseEvent} event + */ + onMouseMove({ clientX }) { + if (this.state === STATE_CLOSED) { + if (clientX < this.xNearWindowEdge) { + this.willOpen(); + } + } else if (this.state === STATE_WILL_OPEN) { + if (clientX >= this.xNearWindowEdge) { + this.close(); + } + } else if (this.state === STATE_OPEN) { + if (clientX >= this.xAwayFromSidebar) { + this.close(); + } else if (clientX >= this.xSidebarEdge) { + this.willClose(); + } + } else if (this.state === STATE_WILL_CLOSE) { + if (clientX >= this.xAwayFromSidebar) { + this.close(); + } else if (clientX < this.xSidebarEdge) { + this.open(); + } + } + }, + onDocumentLeave() { + if (this.state === STATE_OPEN) { + this.willClose(); + } else if (this.state === STATE_WILL_OPEN) { + this.close(); + } + }, + willClose() { + if (this.changeState(STATE_WILL_CLOSE)) { + this.closeTimer = setTimeout(this.close, SUPER_SIDEBAR_PEEK_CLOSE_DELAY); + } + }, + willOpen() { + if (this.changeState(STATE_WILL_OPEN)) { + this.openTimer = setTimeout(this.open, SUPER_SIDEBAR_PEEK_OPEN_DELAY); + } + }, + open() { + if (this.changeState(STATE_OPEN)) { + this.clearTimers(); + } + }, + close() { + if (this.changeState(STATE_CLOSED)) { + this.clearTimers(); + } + }, + clearTimers() { + clearTimeout(this.closeTimer); + clearTimeout(this.openTimer); + }, + /** + * Switches to the new state, and emits a change event. + * + * If the given state is the current state, do nothing. + * + * @param {string} state The state to transition to. + * @returns {boolean} True if the state changed, false otherwise. + */ + changeState(state) { + if (this.state === state) return false; + + this.state = state; + this.$emit('change', state); + return true; + }, + }, + render() { + return null; + }, +}; +</script> diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_portal.vue b/app/assets/javascripts/super_sidebar/components/sidebar_portal.vue new file mode 100644 index 00000000000..2a805c86a3b --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/sidebar_portal.vue @@ -0,0 +1,30 @@ +<script> +import { MountingPortal } from 'portal-vue'; +import { SIDEBAR_PORTAL_ID, portalState } from '../constants'; + +/** + * Use this component to render content into the sidebar. + * + * Arbitrary content is allowed, but nav items should be added using a Ruby + * Sidebars::Panel subclass instead. + * + * Only one instance of this component on a given page is supported. This is to + * avoid ordering issues and cluttering the sidebar. + */ +export default { + components: { + MountingPortal, + }, + data() { + // This is shared state, by design. Do not mutate this state here. + return portalState; + }, + mountSelector: `#${SIDEBAR_PORTAL_ID}`, +}; +</script> + +<template> + <mounting-portal v-if="ready" :mount-to="$options.mountSelector" append> + <slot></slot> + </mounting-portal> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_portal_target.vue b/app/assets/javascripts/super_sidebar/components/sidebar_portal_target.vue new file mode 100644 index 00000000000..1154a4357e0 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/sidebar_portal_target.vue @@ -0,0 +1,17 @@ +<script> +import { SIDEBAR_PORTAL_ID, portalState } from '../constants'; + +export default { + mounted() { + portalState.ready = true; + }, + beforeDestroy() { + portalState.ready = false; + }, + mountId: SIDEBAR_PORTAL_ID, +}; +</script> + +<template> + <div v-once :id="$options.mountId"></div> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue index c4b769dcf24..6b1efc4217c 100644 --- a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue +++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue @@ -1,20 +1,35 @@ <script> -import { GlCollapse } from '@gitlab/ui'; -import { context } from '../mock_data'; +import { GlButton } from '@gitlab/ui'; +import { Mousetrap } from '~/lib/mousetrap'; +import { keysFor, TOGGLE_SUPER_SIDEBAR } from '~/behaviors/shortcuts/keybindings'; +import { __ } from '~/locale'; +import { sidebarState } from '../constants'; +import { isCollapsed, toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager'; import UserBar from './user_bar.vue'; -import ContextSwitcherToggle from './context_switcher_toggle.vue'; +import SidebarPortalTarget from './sidebar_portal_target.vue'; import ContextSwitcher from './context_switcher.vue'; import HelpCenter from './help_center.vue'; +import SidebarMenu from './sidebar_menu.vue'; +import SidebarPeekBehavior, { STATE_CLOSED, STATE_WILL_OPEN } from './sidebar_peek_behavior.vue'; export default { - context, components: { - GlCollapse, + GlButton, UserBar, - ContextSwitcherToggle, ContextSwitcher, HelpCenter, + SidebarMenu, + SidebarPeekBehavior, + SidebarPortalTarget, + TrialStatusWidget: () => + import('ee_component/contextual_sidebar/components/trial_status_widget.vue'), + TrialStatusPopover: () => + import('ee_component/contextual_sidebar/components/trial_status_popover.vue'), }, + i18n: { + skipToMainContent: __('Skip to main content'), + }, + inject: ['showTrialStatusWidget'], props: { sidebarData: { type: Object, @@ -23,29 +38,132 @@ export default { }, data() { return { - contextSwitcherOpened: false, + sidebarState, + showPeekHint: false, }; }, + computed: { + menuItems() { + return this.sidebarData.current_menu_items || []; + }, + peekClasses() { + return { + 'super-sidebar-peek-hint': this.showPeekHint, + 'super-sidebar-peek': this.sidebarState.isPeek, + }; + }, + }, + watch: { + 'sidebarState.isCollapsed': function isCollapsedWatcher(newIsCollapsed) { + if (newIsCollapsed) { + this.$refs['context-switcher'].close(); + } + }, + }, + mounted() { + Mousetrap.bind(keysFor(TOGGLE_SUPER_SIDEBAR), this.toggleSidebar); + }, + beforeDestroy() { + Mousetrap.unbind(keysFor(TOGGLE_SUPER_SIDEBAR)); + }, + methods: { + toggleSidebar() { + toggleSuperSidebarCollapsed(!isCollapsed(), true); + }, + collapseSidebar() { + toggleSuperSidebarCollapsed(true, false); + }, + onPeekChange(state) { + if (state === STATE_CLOSED) { + this.sidebarState.isPeek = false; + this.sidebarState.isCollapsed = true; + this.showPeekHint = false; + } else if (state === STATE_WILL_OPEN) { + this.sidebarState.isPeek = false; + this.sidebarState.isCollapsed = true; + this.showPeekHint = true; + } else { + this.sidebarState.isPeek = true; + this.sidebarState.isCollapsed = false; + this.showPeekHint = false; + } + }, + onContextSwitcherToggled(open) { + this.sidebarState.contextSwitcherOpen = open; + }, + }, }; </script> <template> - <aside - id="super-sidebar" - class="super-sidebar gl-fixed gl-bottom-0 gl-left-0 gl-display-flex gl-flex-direction-column gl-bg-gray-10 gl-border-r gl-border-gray-a-08" - data-testid="super-sidebar" - > - <user-bar :sidebar-data="sidebarData" /> - <div class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-overflow-hidden"> - <div class="gl-flex-grow-1 gl-overflow-auto"> - <context-switcher-toggle :context="$options.context" :expanded="contextSwitcherOpened" /> - <gl-collapse id="context-switcher" v-model="contextSwitcherOpened"> - <context-switcher /> - </gl-collapse> + <div> + <div class="super-sidebar-overlay" @click="collapseSidebar"></div> + <gl-button + class="super-sidebar-skip-to gl-sr-only-focusable gl-fixed gl-left-0 gl-m-3" + href="#content-body" + variant="confirm" + > + {{ $options.i18n.skipToMainContent }} + </gl-button> + <aside + id="super-sidebar" + class="super-sidebar" + :class="peekClasses" + data-testid="super-sidebar" + data-qa-selector="navbar" + :inert="sidebarState.isCollapsed" + > + <user-bar :has-collapse-button="!sidebarState.isPeek" :sidebar-data="sidebarData" /> + <div v-if="showTrialStatusWidget" class="gl-px-2 gl-py-2"> + <trial-status-widget + class="gl-rounded-base gl-relative gl-display-flex gl-align-items-center gl-mb-1 gl-px-3 gl-line-height-normal gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! nav-item-link gl-py-3" + /> + <trial-status-popover /> </div> - <div class="gl-p-3"> - <help-center :sidebar-data="sidebarData" /> + <div class="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 + ref="context-switcher" + :persistent-links="sidebarData.context_switcher_links" + :username="sidebarData.username" + :projects-path="sidebarData.projects_path" + :groups-path="sidebarData.groups_path" + :current-context="sidebarData.current_context" + :context-header="sidebarData.current_context_header" + @toggle="onContextSwitcherToggled" + /> + <sidebar-menu + v-if="menuItems.length" + :items="menuItems" + :panel-type="sidebarData.panel_type" + :pinned-item-ids="sidebarData.pinned_items" + :update-pins-url="sidebarData.update_pins_url" + /> + <sidebar-portal-target /> + </div> + <div class="gl-p-3"> + <help-center :sidebar-data="sidebarData" /> + </div> </div> - </div> - </aside> + </aside> + <a + v-for="shortcutLink in sidebarData.shortcut_links" + :key="shortcutLink.href" + :href="shortcutLink.href" + :class="shortcutLink.css_class" + class="gl-display-none" + > + {{ shortcutLink.title }} + </a> + + <!-- + Only mount SidebarPeekBehavior if the sidebar is peekable, to avoid + setting up event listeners unnecessarily. + --> + <sidebar-peek-behavior v-if="sidebarState.isPeekable" @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 new file mode 100644 index 00000000000..4fff5cf832e --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/super_sidebar_toggle.vue @@ -0,0 +1,80 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { JS_TOGGLE_COLLAPSE_CLASS, JS_TOGGLE_EXPAND_CLASS, sidebarState } from '../constants'; +import { toggleSuperSidebarCollapsed } from '../super_sidebar_collapsed_state_manager'; + +export default { + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + tooltipContainer: { + type: String, + required: false, + default: null, + }, + tooltipPlacement: { + type: String, + required: false, + default: 'right', + }, + }, + i18n: { + collapseSidebar: __('Hide sidebar'), + expandSidebar: __('Show sidebar'), + navigationSidebar: __('Navigation sidebar'), + }, + data() { + return sidebarState; + }, + computed: { + tooltipTitle() { + if (this.isPeek) return ''; + + return this.isCollapsed + ? this.$options.i18n.expandSidebar + : this.$options.i18n.collapseSidebar; + }, + tooltip() { + return { + placement: this.tooltipPlacement, + container: this.tooltipContainer, + title: this.tooltipTitle, + }; + }, + ariaExpanded() { + return String(!this.isCollapsed); + }, + }, + methods: { + toggle() { + toggleSuperSidebarCollapsed(!this.isCollapsed, true); + this.focusOtherToggle(); + }, + focusOtherToggle() { + this.$nextTick(() => { + const classSelector = this.isCollapsed ? JS_TOGGLE_EXPAND_CLASS : JS_TOGGLE_COLLAPSE_CLASS; + const otherToggle = document.querySelector(`.${classSelector}`); + otherToggle?.focus(); + }); + }, + }, +}; +</script> + +<template> + <gl-button + v-gl-tooltip.hover="tooltip" + aria-controls="super-sidebar" + :aria-expanded="ariaExpanded" + :aria-label="$options.i18n.navigationSidebar" + icon="sidebar" + category="tertiary" + :disabled="isPeek" + @click="toggle" + /> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue index ee72e8eafb4..768914584e8 100644 --- a/app/assets/javascripts/super_sidebar/components/user_bar.vue +++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue @@ -1,90 +1,215 @@ <script> -import { GlAvatar, GlDropdown, GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { GlBadge, GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; import SafeHtml from '~/vue_shared/directives/safe_html'; -import NewNavToggle from '~/nav/components/new_nav_toggle.vue'; +import { + destroyUserCountsManager, + createUserCountsManager, + userCounts, +} from '~/super_sidebar/user_counts_manager'; import logo from '../../../../views/shared/_logo.svg'; +import { JS_TOGGLE_COLLAPSE_CLASS } from '../constants'; import CreateMenu from './create_menu.vue'; import Counter from './counter.vue'; import MergeRequestMenu from './merge_request_menu.vue'; +import UserMenu from './user_menu.vue'; +import SuperSidebarToggle from './super_sidebar_toggle.vue'; +import { SEARCH_MODAL_ID } from './global_search/constants'; export default { + // "GitLab Next" is a proper noun, so don't translate "Next" + /* eslint-disable-next-line @gitlab/require-i18n-strings */ + NEXT_LABEL: 'Next', logo, + JS_TOGGLE_COLLAPSE_CLASS, + SEARCH_MODAL_ID, components: { - GlAvatar, - GlDropdown, - GlIcon, - CreateMenu, - NewNavToggle, Counter, + CreateMenu, + GlBadge, + GlButton, MergeRequestMenu, + UserMenu, + SearchModal: () => + import( + /* webpackChunkName: 'global_search_modal' */ './global_search/components/global_search.vue' + ), + SuperSidebarToggle, }, i18n: { createNew: __('Create new...'), + homepage: __('Homepage'), issues: __('Issues'), mergeRequests: __('Merge requests'), + search: __('Search'), + searchKbdHelp: sprintf( + s__('GlobalSearch|Search GitLab %{kbdOpen}/%{kbdClose}'), + { kbdOpen: '<kbd>', kbdClose: '</kbd>' }, + false, + ), todoList: __('To-Do list'), + stopImpersonating: __('Stop impersonating'), }, directives: { GlTooltip: GlTooltipDirective, + GlModal: GlModalDirective, SafeHtml, }, - inject: ['rootPath', 'toggleNewNavEndpoint'], + inject: ['rootPath', 'isImpersonating'], props: { + hasCollapseButton: { + default: true, + type: Boolean, + required: false, + }, sidebarData: { type: Object, required: true, }, }, + data() { + return { + mrMenuShown: false, + searchTooltip: this.$options.i18n.searchKbdHelp, + userCounts, + }; + }, + computed: { + mergeRequestTotalCount() { + return userCounts.assigned_merge_requests + userCounts.review_requested_merge_requests; + }, + }, + created() { + Object.assign(userCounts, this.sidebarData.user_counts); + createUserCountsManager(); + }, + mounted() { + document.addEventListener('todo:toggle', this.updateTodos); + }, + beforeDestroy() { + document.removeEventListener('todo:toggle', this.updateTodos); + destroyUserCountsManager(); + }, + methods: { + updateTodos(e) { + userCounts.todos = e.detail.count || 0; + }, + hideSearchTooltip() { + this.searchTooltip = ''; + }, + showSearchTooltip() { + this.searchTooltip = this.$options.i18n.searchKbdHelp; + }, + }, }; </script> <template> <div class="user-bar"> - <div class="gl-display-flex gl-align-items-center gl-px-3 gl-py-2 gl-gap-3"> - <div class="gl-flex-grow-1"> - <a v-safe-html="$options.logo" :href="rootPath"></a> - </div> + <div class="gl-display-flex gl-align-items-center gl-px-3 gl-py-2"> + <a + v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.homepage" + class="tanuki-logo-container" + :href="rootPath" + :title="$options.i18n.homepage" + data-track-action="click_link" + data-track-label="gitlab_logo_link" + data-track-property="nav_core_menu" + > + <img + v-if="sidebarData.logo_url" + data-testid="brand-header-custom-logo" + :src="sidebarData.logo_url" + class="gl-h-6" + /> + <span v-else v-safe-html="$options.logo"></span> + </a> + <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> + <super-sidebar-toggle + v-if="hasCollapseButton" + :class="$options.JS_TOGGLE_COLLAPSE_CLASS" + tooltip-placement="bottom" + tooltip-container="super-sidebar" + data-testid="super-sidebar-collapse-button" + /> <create-menu :groups="sidebarData.create_new_menu_groups" /> - <button class="gl-border-none"> - <gl-icon name="search" class="gl-vertical-align-middle" /> - </button> - <gl-dropdown data-testid="user-dropdown" variant="link" no-caret> - <template #button-content> - <gl-avatar :entity-name="sidebarData.name" :src="sidebarData.avatar_url" :size="32" /> - </template> - <new-nav-toggle :endpoint="toggleNewNavEndpoint" enabled /> - </gl-dropdown> + + <gl-button + id="super-sidebar-search" + v-gl-tooltip.bottom.hover.html="searchTooltip" + v-gl-modal="$options.SEARCH_MODAL_ID" + data-testid="super-sidebar-search-button" + data-qa-selector="global_search_button" + icon="search" + :aria-label="$options.i18n.search" + category="tertiary" + /> + <search-modal @shown="hideSearchTooltip" @hidden="showSearchTooltip" /> + + <user-menu :data="sidebarData" /> + + <gl-button + v-if="isImpersonating" + v-gl-tooltip + :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"> <counter v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.issues" - class="gl-flex-basis-third" + class="gl-flex-basis-third dashboard-shortcuts-issues" icon="issues" - :count="sidebarData.assigned_open_issues_count" + :count="userCounts.assigned_issues" :href="sidebarData.issues_dashboard_path" :label="$options.i18n.issues" + data-track-action="click_link" + data-track-label="issues_link" + data-track-property="nav_core_menu" /> <merge-request-menu class="gl-flex-basis-third gl-display-block!" :items="sidebarData.merge_request_menu" + @shown="mrMenuShown = true" + @hidden="mrMenuShown = false" > <counter - v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.mergeRequests" + v-gl-tooltip:super-sidebar.hover.bottom="mrMenuShown ? '' : $options.i18n.mergeRequests" class="gl-w-full" - tabindex="-1" icon="merge-request-open" - :count="sidebarData.total_merge_requests_count" + :count="mergeRequestTotalCount" :label="$options.i18n.mergeRequests" + data-track-action="click_dropdown" + data-track-label="merge_requests_menu" + data-track-property="nav_core_menu" /> </merge-request-menu> <counter v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.todoList" - class="gl-flex-basis-third" + class="gl-flex-basis-third shortcuts-todos js-todos-count" icon="todo-done" - :count="sidebarData.todos_pending_count" - href="/dashboard/todos" + :count="userCounts.todos" + :href="sidebarData.todos_dashboard_path" :label="$options.i18n.todoList" + data-qa-selector="todos_shortcut_button" + data-track-action="click_link" + data-track-label="todos_link" + data-track-property="nav_core_menu" /> </div> </div> diff --git a/app/assets/javascripts/super_sidebar/components/user_menu.vue b/app/assets/javascripts/super_sidebar/components/user_menu.vue new file mode 100644 index 00000000000..cd5a83c86cc --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue @@ -0,0 +1,340 @@ +<script> +import { + GlAvatar, + GlDisclosureDropdown, + GlDisclosureDropdownGroup, + GlDisclosureDropdownItem, + GlButton, +} from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { s__, __, sprintf } from '~/locale'; +import NewNavToggle from '~/nav/components/new_nav_toggle.vue'; +import Tracking from '~/tracking'; +import PersistentUserCallout from '~/persistent_user_callout'; +import { USER_MENU_TRACKING_DEFAULTS, DROPDOWN_Y_OFFSET } from '../constants'; +import UserNameGroup from './user_name_group.vue'; + +// Left offset required for the dropdown to be aligned with the super sidebar +const DROPDOWN_X_OFFSET = -211; + +export default { + feedbackUrl: 'https://gitlab.com/gitlab-org/gitlab/-/issues/409005', + i18n: { + newNavigation: { + sectionTitle: s__('NorthstarNavigation|Navigation redesign'), + }, + setStatus: s__('SetStatusModal|Set status'), + editStatus: s__('SetStatusModal|Edit status'), + editProfile: s__('CurrentUser|Edit profile'), + preferences: s__('CurrentUser|Preferences'), + buyPipelineMinutes: s__('CurrentUser|Buy Pipeline minutes'), + oneOfGroupsRunningOutOfPipelineMinutes: s__('CurrentUser|One of your groups is running out'), + gitlabNext: s__('CurrentUser|Switch to GitLab Next'), + provideFeedback: s__('NorthstarNavigation|Provide feedback'), + startTrial: s__('CurrentUser|Start an Ultimate trial'), + signOut: __('Sign out'), + }, + components: { + GlAvatar, + GlDisclosureDropdown, + GlDisclosureDropdownGroup, + GlDisclosureDropdownItem, + GlButton, + NewNavToggle, + UserNameGroup, + }, + directives: { + SafeHtml, + }, + mixins: [Tracking.mixin()], + inject: ['toggleNewNavEndpoint'], + props: { + data: { + required: true, + type: Object, + }, + }, + computed: { + toggleText() { + return sprintf(__('%{user} user’s menu'), { user: this.data.name }); + }, + statusItem() { + const { busy, customized } = this.data.status; + + const statusLabel = + busy || customized ? this.$options.i18n.editStatus : this.$options.i18n.setStatus; + + return { + text: statusLabel, + extraAttrs: { + class: 'js-set-status-modal-trigger', + }, + }; + }, + trialItem() { + return { + text: this.$options.i18n.startTrial, + href: this.data.trial.url, + extraAttrs: { + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'start_trial', + }, + }; + }, + showTrialItem() { + return this.data.trial?.has_start_trial; + }, + editProfileItem() { + return { + text: this.$options.i18n.editProfile, + href: this.data.settings.profile_path, + extraAttrs: { + 'data-qa-selector': 'edit_profile_link', + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'user_edit_profile', + }, + }; + }, + preferencesItem() { + return { + text: this.$options.i18n.preferences, + href: this.data.settings.profile_preferences_path, + extraAttrs: { + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'user_preferences', + }, + }; + }, + addBuyPipelineMinutesMenuItem() { + return this.data.pipeline_minutes?.show_buy_pipeline_minutes; + }, + buyPipelineMinutesItem() { + return { + text: this.$options.i18n.buyPipelineMinutes, + warningText: this.$options.i18n.oneOfGroupsRunningOutOfPipelineMinutes, + href: this.data.pipeline_minutes?.buy_pipeline_minutes_path, + extraAttrs: { + class: 'js-follow-link', + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'buy_pipeline_minutes', + }, + }; + }, + gitlabNextItem() { + return { + text: this.$options.i18n.gitlabNext, + href: this.data.canary_toggle_com_url, + extraAttrs: { + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'switch_to_canary', + }, + }; + }, + feedbackItem() { + return { + text: this.$options.i18n.provideFeedback, + href: this.$options.feedbackUrl, + extraAttrs: { + target: '_blank', + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'provide_nav_feedback', + }, + }; + }, + signOutGroup() { + return { + items: [ + { + text: this.$options.i18n.signOut, + href: this.data.sign_out_link, + extraAttrs: { + 'data-method': 'post', + 'data-qa-selector': 'sign_out_link', + class: 'sign-out-link', + }, + }, + ], + }; + }, + statusModalData() { + const defaultData = { + 'data-current-emoji': '', + 'data-current-message': '', + 'data-default-emoji': 'speech_balloon', + }; + + const { busy, customized } = this.data.status; + + if (!busy && !customized) { + return defaultData; + } + + return { + ...defaultData, + 'data-current-emoji': this.data.status.emoji, + 'data-current-message': this.data.status.message, + 'data-current-availability': this.data.status.availability, + 'data-current-clear-status-after': this.data.status.clear_after, + }; + }, + buyPipelineMinutesCalloutData() { + return this.showNotificationDot + ? { + 'data-feature-id': this.data.pipeline_minutes.callout_attrs.feature_id, + 'data-dismiss-endpoint': this.data.pipeline_minutes.callout_attrs.dismiss_endpoint, + } + : {}; + }, + showNotificationDot() { + return this.data.pipeline_minutes?.show_notification_dot; + }, + }, + methods: { + onShow() { + this.initBuyCIMinsCallout(); + }, + closeDropdown() { + this.$refs.userDropdown.close(); + }, + initBuyCIMinsCallout() { + if (this.showNotificationDot) { + PersistentUserCallout.factory(this.$refs?.buyPipelineMinutesNotificationCallout.$el); + } + }, + /* We're not sure this event is tracked by anyone + whether it stays will depend on the outcome of this discussion: + https://gitlab.com/gitlab-org/gitlab/-/issues/402713#note_1343072135 */ + trackBuyCIMins() { + if (this.addBuyPipelineMinutesMenuItem) { + const { + 'track-action': trackAction, + 'track-label': label, + 'track-property': property, + } = this.data.pipeline_minutes.tracking_attrs; + this.track(trackAction, { label, property }); + } + }, + trackSignOut() { + this.track(USER_MENU_TRACKING_DEFAULTS['data-track-action'], { + label: 'user_sign_out', + property: USER_MENU_TRACKING_DEFAULTS['data-track-property'], + }); + }, + }, + popperOptions: { + modifiers: [ + { + name: 'offset', + options: { + offset: [DROPDOWN_X_OFFSET, DROPDOWN_Y_OFFSET], + }, + }, + ], + }, +}; +</script> + +<template> + <div> + <gl-disclosure-dropdown + ref="userDropdown" + :popper-options="$options.popperOptions" + data-testid="user-dropdown" + data-qa-selector="user_menu" + @shown="onShow" + > + <template #toggle> + <gl-button category="tertiary" class="user-bar-item btn-with-notification"> + <span class="gl-sr-only">{{ toggleText }}</span> + <gl-avatar + :size="24" + :entity-name="data.name" + :src="data.avatar_url" + aria-hidden="true" + data-qa-selector="user_avatar_content" + /> + <span + v-if="showNotificationDot" + class="notification-dot-warning" + data-testid="buy-pipeline-minutes-notification-dot" + v-bind="data.pipeline_minutes.notification_dot_attrs" + > + </span> + </gl-button> + </template> + + <user-name-group :user="data" /> + <gl-disclosure-dropdown-group bordered> + <gl-disclosure-dropdown-item + v-if="data.status.can_update" + :item="statusItem" + data-testid="status-item" + @action="closeDropdown" + /> + + <gl-disclosure-dropdown-item + v-if="showTrialItem" + :item="trialItem" + data-testid="start-trial-item" + > + <template #list-item> + {{ trialItem.text }} + <gl-emoji data-name="rocket" /> + </template> + </gl-disclosure-dropdown-item> + + <gl-disclosure-dropdown-item :item="editProfileItem" data-testid="edit-profile-item" /> + + <gl-disclosure-dropdown-item :item="preferencesItem" data-testid="preferences-item" /> + + <gl-disclosure-dropdown-item + v-if="addBuyPipelineMinutesMenuItem" + ref="buyPipelineMinutesNotificationCallout" + :item="buyPipelineMinutesItem" + v-bind="buyPipelineMinutesCalloutData" + data-testid="buy-pipeline-minutes-item" + @action="trackBuyCIMins" + > + <template #list-item> + <span class="gl-display-flex gl-flex-direction-column"> + <span>{{ buyPipelineMinutesItem.text }} <gl-emoji data-name="clock9" /></span> + <span + v-if="data.pipeline_minutes.show_with_subtext" + class="gl-font-sm small gl-pt-2 gl-text-orange-800" + >{{ buyPipelineMinutesItem.warningText }}</span + > + </span> + </template> + </gl-disclosure-dropdown-item> + + <gl-disclosure-dropdown-item + v-if="data.gitlab_com_but_not_canary" + :item="gitlabNextItem" + data-testid="gitlab-next-item" + /> + </gl-disclosure-dropdown-group> + + <gl-disclosure-dropdown-group bordered> + <template #group-label> + <span class="gl-font-sm">{{ $options.i18n.newNavigation.sectionTitle }}</span> + </template> + <new-nav-toggle :endpoint="toggleNewNavEndpoint" enabled new-navigation /> + <gl-disclosure-dropdown-item :item="feedbackItem" data-testid="feedback-item" /> + </gl-disclosure-dropdown-group> + + <gl-disclosure-dropdown-group + v-if="data.can_sign_out" + bordered + :group="signOutGroup" + data-testid="sign-out-group" + @action="trackSignOut" + /> + </gl-disclosure-dropdown> + + <div + v-if="data.status.can_update" + class="js-set-status-modal-wrapper" + v-bind="statusModalData" + ></div> + </div> +</template> diff --git a/app/assets/javascripts/super_sidebar/components/user_name_group.vue b/app/assets/javascripts/super_sidebar/components/user_name_group.vue new file mode 100644 index 00000000000..dfaaaccf4a4 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/components/user_name_group.vue @@ -0,0 +1,90 @@ +<script> +import { + GlBadge, + GlDisclosureDropdownGroup, + GlDisclosureDropdownItem, + GlTooltip, +} from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { s__ } from '~/locale'; +import { USER_MENU_TRACKING_DEFAULTS } from '../constants'; + +export default { + i18n: { + user: { + busy: s__('UserProfile|Busy'), + }, + }, + components: { + GlBadge, + GlDisclosureDropdownGroup, + GlDisclosureDropdownItem, + GlTooltip, + }, + directives: { + SafeHtml, + }, + props: { + user: { + required: true, + type: Object, + }, + }, + computed: { + menuItem() { + const item = { + text: this.user.name, + }; + if (this.user.has_link_to_profile) { + item.href = this.user.link_to_profile; + + item.extraAttrs = { + ...USER_MENU_TRACKING_DEFAULTS, + 'data-track-label': 'user_profile', + 'data-qa-selector': 'user_profile_link', + }; + } + + return item; + }, + }, +}; +</script> + +<template> + <gl-disclosure-dropdown-group> + <gl-disclosure-dropdown-item :item="menuItem"> + <template #list-item> + <span class="gl-display-flex gl-flex-direction-column"> + <span> + <span class="gl-font-weight-bold"> + {{ user.name }} + </span> + <gl-badge v-if="user.status.busy" size="sm" variant="warning"> + {{ $options.i18n.user.busy }} + </gl-badge> + </span> + + <span class="gl-text-gray-400">@{{ user.username }}</span> + + <span + v-if="user.status.customized" + ref="statusTooltipTarget" + data-testid="user-menu-status" + class="gl-display-flex gl-align-items-center gl-mt-2 gl-font-sm" + > + <gl-emoji :data-name="user.status.emoji" class="gl-mr-1" /> + <span v-safe-html="user.status.message" class="gl-text-truncate"></span> + <gl-tooltip + :target="() => $refs.statusTooltipTarget" + boundary="viewport" + placement="bottom" + > + <span v-safe-html="user.status.message"></span> + </gl-tooltip> + </span> + </span> + </template> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown-group> +</template> diff --git a/app/assets/javascripts/super_sidebar/constants.js b/app/assets/javascripts/super_sidebar/constants.js new file mode 100644 index 00000000000..00ceaebe2cc --- /dev/null +++ b/app/assets/javascripts/super_sidebar/constants.js @@ -0,0 +1,54 @@ +// Note: all constants defined here are considered internal implementation +// details for the sidebar. They should not be imported by anything outside of +// the super_sidebar directory. + +import Vue from 'vue'; + +export const SIDEBAR_PORTAL_ID = 'sidebar-portal-mount'; +export const JS_TOGGLE_COLLAPSE_CLASS = 'js-super-sidebar-toggle-collapse'; +export const JS_TOGGLE_EXPAND_CLASS = 'js-super-sidebar-toggle-expand'; + +export const portalState = Vue.observable({ + ready: false, +}); + +export const sidebarState = Vue.observable({ + contextSwitcherOpen: false, + isCollapsed: false, + isPeek: false, + isPeekable: false, +}); + +export const helpCenterState = Vue.observable({ + showTanukiBotChatDrawer: false, +}); + +export const MAX_FREQUENT_PROJECTS_COUNT = 5; +export const MAX_FREQUENT_GROUPS_COUNT = 3; + +export const SUPER_SIDEBAR_PEEK_OPEN_DELAY = 200; +export const SUPER_SIDEBAR_PEEK_CLOSE_DELAY = 500; + +export const TRACKING_UNKNOWN_ID = 'item_without_id'; +export const TRACKING_UNKNOWN_PANEL = 'nav_panel_unknown'; +export const CLICK_MENU_ITEM_ACTION = 'click_menu_item'; +export const CLICK_PINNED_MENU_ITEM_ACTION = 'click_pinned_menu_item'; + +export const PANELS_WITH_PINS = ['group', 'project']; + +export const USER_MENU_TRACKING_DEFAULTS = { + 'data-track-property': 'nav_user_menu', + 'data-track-action': 'click_link', +}; + +export const HELP_MENU_TRACKING_DEFAULTS = { + 'data-track-property': 'nav_help_menu', + 'data-track-action': 'click_link', +}; + +export const SIDEBAR_PINS_EXPANDED_COOKIE = 'sidebar_pinned_section_expanded'; +export const SIDEBAR_COOKIE_EXPIRATION = 365 * 10; + +export const DROPDOWN_Y_OFFSET = 4; + +export const NAV_ITEM_LINK_ACTIVE_CLASS = 'gl-bg-t-gray-a-08'; diff --git a/app/assets/javascripts/super_sidebar/graphql/queries/search_user_groups_and_projects.query.graphql b/app/assets/javascripts/super_sidebar/graphql/queries/search_user_groups_and_projects.query.graphql new file mode 100644 index 00000000000..4b1e65be3fa --- /dev/null +++ b/app/assets/javascripts/super_sidebar/graphql/queries/search_user_groups_and_projects.query.graphql @@ -0,0 +1,24 @@ +query searchUserProjectsAndGroups($username: String!, $search: String) { + projects(search: $search, sort: "latest_activity_desc", membership: true, first: 20) { + nodes { + id + name + namespace: nameWithNamespace + webUrl + avatarUrl + } + } + + user(username: $username) { + id + groups(search: $search, first: 20) { + nodes { + id + name + namespace: fullPath + webUrl + avatarUrl + } + } + } +} diff --git a/app/assets/javascripts/super_sidebar/mock_data.js b/app/assets/javascripts/super_sidebar/mock_data.js deleted file mode 100644 index 0d1ac006df7..00000000000 --- a/app/assets/javascripts/super_sidebar/mock_data.js +++ /dev/null @@ -1,59 +0,0 @@ -import { s__ } from '~/locale'; - -export const context = { - title: 'Typeahead.js', - link: '/', - avatar: 'https://gitlab.com/uploads/-/system/project/avatar/278964/project_avatar.png?width=32', -}; - -export const contextSwitcherItems = { - yourWork: { title: s__('Navigation|Your work'), link: '/', icon: 'work' }, - recentProjects: [ - { - // eslint-disable-next-line @gitlab/require-i18n-strings - title: 'Orange', - subtitle: 'tropical-tree', - link: '/tropical-tree', - avatar: - 'https://gitlab.com/uploads/-/system/project/avatar/4456656/pajamas-logo.png?width=64', - }, - { - // eslint-disable-next-line @gitlab/require-i18n-strings - title: 'Lemon', - subtitle: 'tropical-tree', - link: '/tropical-tree', - avatar: 'https://gitlab.com/uploads/-/system/project/avatar/7071551/GitLab_UI.png?width=64', - }, - { - // eslint-disable-next-line @gitlab/require-i18n-strings - title: 'Coconut', - subtitle: 'tropical-tree', - link: '/tropical-tree', - avatar: - 'https://gitlab.com/uploads/-/system/project/avatar/4149988/SVGs_project.png?width=64', - }, - ], - recentGroups: [ - { - title: 'Developer Evangelism at GitLab', - subtitle: 'tropical-tree', - link: '/tropical-tree', - avatar: - 'https://gitlab.com/uploads/-/system/group/avatar/10087220/rainbow_tanuki.jpg?width=64', - }, - { - title: 'security-products', - subtitle: 'tropical-tree', - link: '/tropical-tree', - avatar: - 'https://gitlab.com/uploads/-/system/group/avatar/11932235/gitlab-icon-rgb.png?width=64', - }, - { - title: 'Tanuki-Workshops', - subtitle: 'tropical-tree', - link: '/tropical-tree', - avatar: - 'https://gitlab.com/uploads/-/system/group/avatar/5085244/Screenshot_2019-04-29_at_16.13.07.png?width=64', - }, - ], -}; diff --git a/app/assets/javascripts/super_sidebar/popper_max_size_modifier.js b/app/assets/javascripts/super_sidebar/popper_max_size_modifier.js new file mode 100644 index 00000000000..6581d521107 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/popper_max_size_modifier.js @@ -0,0 +1,43 @@ +import { detectOverflow } from '@popperjs/core'; + +/** + * These modifiers were copied from the community modifier popper-max-size-modifier + * https://www.npmjs.com/package/popper-max-size-modifier. + * We are considering upgrading Popper.js to Floating UI, at which point the behavior this + * introduces will be available out of the box. + * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2213 + */ + +export const maxSize = { + name: 'maxSize', + enabled: true, + phase: 'main', + requiresIfExists: ['offset', 'preventOverflow', 'flip'], + fn({ state, name }) { + const overflow = detectOverflow(state); + const { x, y } = state.modifiersData.preventOverflow || { x: 0, y: 0 }; + const { width, height } = state.rects.popper; + const [basePlacement] = state.placement.split('-'); + + const widthProp = basePlacement === 'left' ? 'left' : 'right'; + const heightProp = basePlacement === 'top' ? 'top' : 'bottom'; + + state.modifiersData[name] = { + width: width - overflow[widthProp] - x, + height: height - overflow[heightProp] - y, + }; + }, +}; + +export const applyMaxSize = { + name: 'applyMaxSize', + enabled: true, + phase: 'write', + requires: ['maxSize'], + fn({ state }) { + // The `maxSize` modifier provides this data + const { width, height } = state.modifiersData.maxSize; + state.elements.popper.style.maxWidth = `${width}px`; + state.elements.popper.style.maxHeight = `${height}px`; + }, +}; diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js index b9c7073df8c..63424277ffc 100644 --- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js +++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js @@ -1,26 +1,131 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; +import { initStatusTriggers } from '../header'; +import { JS_TOGGLE_EXPAND_CLASS } from './constants'; +import createStore from './components/global_search/store'; +import { + bindSuperSidebarCollapsedEvents, + initSuperSidebarCollapsedState, +} from './super_sidebar_collapsed_state_manager'; import SuperSidebar from './components/super_sidebar.vue'; +import SuperSidebarToggle from './components/super_sidebar_toggle.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +const getTrialStatusWidgetData = (sidebarData) => { + if (sidebarData.trial_status_widget_data_attrs && sidebarData.trial_status_popover_data_attrs) { + const { + containerId, + trialDaysUsed, + trialDuration, + navIconImagePath, + percentageComplete, + planName, + plansHref, + } = convertObjectPropsToCamelCase(sidebarData.trial_status_widget_data_attrs); + + const { + daysRemaining, + targetId, + trialEndDate, + namespaceId, + userName, + firstName, + lastName, + companyName, + glmContent, + } = convertObjectPropsToCamelCase(sidebarData.trial_status_popover_data_attrs); + + return { + showTrialStatusWidget: true, + containerId, + trialDaysUsed: Number(trialDaysUsed), + trialDuration: Number(trialDuration), + navIconImagePath, + percentageComplete: Number(percentageComplete), + planName, + plansHref, + daysRemaining, + targetId, + trialEndDate: new Date(trialEndDate), + user: { namespaceId, userName, firstName, lastName, companyName, glmContent }, + }; + } + return { showTrialStatusWidget: false }; +}; export const initSuperSidebar = () => { const el = document.querySelector('.js-super-sidebar'); if (!el) return false; - const { rootPath, sidebar, toggleNewNavEndpoint } = el.dataset; + const { rootPath, sidebar, toggleNewNavEndpoint, forceDesktopExpandedSidebar } = el.dataset; + + bindSuperSidebarCollapsedEvents(forceDesktopExpandedSidebar); + initSuperSidebarCollapsedState(parseBoolean(forceDesktopExpandedSidebar)); + + const sidebarData = JSON.parse(sidebar); + const searchData = convertObjectPropsToCamelCase(sidebarData.search); + + const { searchPath, issuesPath, mrPath, autocompletePath, searchContext } = searchData; + const isImpersonating = parseBoolean(sidebarData.is_impersonating); return new Vue({ el, name: 'SuperSidebarRoot', + apolloProvider, provide: { rootPath, toggleNewNavEndpoint, + isImpersonating, + ...getTrialStatusWidgetData(sidebarData), }, + store: createStore({ + searchPath, + issuesPath, + mrPath, + autocompletePath, + searchContext, + search: '', + }), render(h) { return h(SuperSidebar, { props: { - sidebarData: JSON.parse(sidebar), + sidebarData, }, }); }, }); }; + +/** + * Guard against multiple instantiations, since the js-* class is persisted + * in the Vue component. + */ +let toggleInstantiated = false; + +export const initSuperSidebarToggle = () => { + const el = document.querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`); + + if (!el || toggleInstantiated) return false; + + toggleInstantiated = true; + + return new Vue({ + el, + name: 'SuperSidebarToggleRoot', + render(h) { + // Copy classes from HAML-defined button to ensure same positioning, + // including JS_TOGGLE_EXPAND_CLASS. + return h(SuperSidebarToggle, { class: el.className }); + }, + }); +}; + +requestIdleCallback(initStatusTriggers); diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js b/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js new file mode 100644 index 00000000000..1a359533435 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/super_sidebar_collapsed_state_manager.js @@ -0,0 +1,61 @@ +import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils'; +import { debounce } from 'lodash'; +import { setCookie, getCookie } from '~/lib/utils/common_utils'; +import { sidebarState } from './constants'; + +export const SIDEBAR_COLLAPSED_CLASS = 'page-with-super-sidebar-collapsed'; +export const SIDEBAR_COLLAPSED_COOKIE = 'super_sidebar_collapsed'; +export const SIDEBAR_COLLAPSED_COOKIE_EXPIRATION = 365 * 10; +export const SIDEBAR_TRANSITION_DURATION = 200; + +export const findPage = () => document.querySelector('.page-with-super-sidebar'); +export const findSidebar = () => document.querySelector('.super-sidebar'); + +export const isCollapsed = () => findPage().classList.contains(SIDEBAR_COLLAPSED_CLASS); + +// See documentation: https://design.gitlab.com/patterns/navigation#left-sidebar +// NOTE: at 1200px nav sidebar should not overlap the content +// https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/24555#note_134136110 +export const isDesktopBreakpoint = () => bp.windowWidth() >= breakpoints.xl; + +export const getCollapsedCookie = () => getCookie(SIDEBAR_COLLAPSED_COOKIE) === 'true'; + +export const toggleSuperSidebarCollapsed = (collapsed, saveCookie) => { + findPage().classList.toggle(SIDEBAR_COLLAPSED_CLASS, collapsed); + + sidebarState.isPeek = false; + sidebarState.isPeekable = Boolean(gon.features?.superSidebarPeek) && collapsed; + sidebarState.isCollapsed = collapsed; + + if (saveCookie && isDesktopBreakpoint()) { + setCookie(SIDEBAR_COLLAPSED_COOKIE, collapsed, { + expires: SIDEBAR_COLLAPSED_COOKIE_EXPIRATION, + }); + } +}; + +export const initSuperSidebarCollapsedState = (forceDesktopExpandedSidebar = false) => { + let collapsed = true; + if (isDesktopBreakpoint()) { + collapsed = forceDesktopExpandedSidebar ? false : getCollapsedCookie(); + } + toggleSuperSidebarCollapsed(collapsed, false); +}; + +export const bindSuperSidebarCollapsedEvents = (forceDesktopExpandedSidebar = false) => { + let previousWindowWidth = window.innerWidth; + + const callback = debounce(() => { + const newWindowWidth = window.innerWidth; + const widthChanged = previousWindowWidth !== newWindowWidth; + + if (widthChanged) { + initSuperSidebarCollapsedState(forceDesktopExpandedSidebar); + } + previousWindowWidth = newWindowWidth; + }, 100); + + window.addEventListener('resize', callback); + + return () => window.removeEventListener('resize', callback); +}; diff --git a/app/assets/javascripts/super_sidebar/user_counts_manager.js b/app/assets/javascripts/super_sidebar/user_counts_manager.js new file mode 100644 index 00000000000..40c9fc43252 --- /dev/null +++ b/app/assets/javascripts/super_sidebar/user_counts_manager.js @@ -0,0 +1,69 @@ +import Vue from 'vue'; +import { getUserCounts } from '~/api/user_api'; + +export const userCounts = Vue.observable({ + last_update: 0, + // The following fields are part of + // https://docs.gitlab.com/ee/api/users.html#user-counts + todos: 0, + assigned_issues: 0, + assigned_merge_requests: 0, + review_requested_merge_requests: 0, +}); + +function updateCounts(payload = {}) { + if ((payload.last_update ?? 0) < userCounts.last_update) { + return; + } + for (const key in userCounts) { + if (Number.isInteger(payload[key])) { + userCounts[key] = payload[key]; + } + } +} + +let broadcastChannel = null; + +function broadcastUserCounts(data) { + broadcastChannel?.postMessage(data); +} + +async function retrieveUserCountsFromApi() { + try { + const lastUpdate = Date.now(); + const { data } = await getUserCounts(); + const payload = { ...data, last_update: lastUpdate }; + updateCounts(payload); + broadcastUserCounts(userCounts); + } catch (e) { + // eslint-disable-next-line no-console, @gitlab/require-i18n-strings + console.error('Error retrieving user counts', e); + } +} + +export function destroyUserCountsManager() { + document.removeEventListener('userCounts:fetch', retrieveUserCountsFromApi); + broadcastChannel?.close(); + broadcastChannel = null; +} + +/** + * The createUserCountsManager does three things: + * 1. Set the initial state of userCounts + * 2. Create a broadcast channel to communicate user count updates across tabs + * 3. Add event listeners for other parts in the app which: + * - Update todos + * - Trigger a refetch of all counts + */ +export function createUserCountsManager() { + destroyUserCountsManager(); + document.addEventListener('userCounts:fetch', retrieveUserCountsFromApi); + + if (window.BroadcastChannel && gon?.current_user_id) { + broadcastChannel = new BroadcastChannel(`user_counts_${gon?.current_user_id}`); + broadcastChannel.onmessage = (ev) => { + updateCounts(ev.data); + }; + broadcastUserCounts(userCounts); + } +} diff --git a/app/assets/javascripts/super_sidebar/utils.js b/app/assets/javascripts/super_sidebar/utils.js new file mode 100644 index 00000000000..3b17a35c5bc --- /dev/null +++ b/app/assets/javascripts/super_sidebar/utils.js @@ -0,0 +1,87 @@ +import AccessorUtilities from '~/lib/utils/accessor'; +import { FREQUENT_ITEMS, FIFTEEN_MINUTES_IN_MS } from '~/frequent_items/constants'; +import { truncateNamespace } from '~/lib/utils/text_utility'; + +/** + * This takes an array of project or groups that were stored in the local storage, to be shown in + * the context switcher, and sorts them by frequency and last access date. + * In the resulting array, the most popular item (highest frequency and most recent access date) is + * placed at the first index, while the least popular is at the last index. + * + * @param {Array} items The projects or groups stored in the local storage + * @returns The items, sorted by frequency and last access date + */ +const sortItemsByFrequencyAndLastAccess = (items) => + items.sort((itemA, itemB) => { + // Sort all frequent items in decending order of frequency + // and then by lastAccessedOn with recent most first + if (itemA.frequency !== itemB.frequency) { + return itemB.frequency - itemA.frequency; + } else if (itemA.lastAccessedOn !== itemB.lastAccessedOn) { + return itemB.lastAccessedOn - itemA.lastAccessedOn; + } + + return 0; + }); + +// This imitates getTopFrequentItems from app/assets/javascripts/frequent_items/utils.js, but +// adjusts the rules to accommodate for the context switcher's designs. +export const getTopFrequentItems = (items, maxCount) => { + if (!Array.isArray(items)) return []; + + const frequentItems = items.filter((item) => item.frequency >= FREQUENT_ITEMS.ELIGIBLE_FREQUENCY); + sortItemsByFrequencyAndLastAccess(frequentItems); + + return frequentItems.slice(0, maxCount); +}; + +const updateItemAccess = (item) => { + 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; + + return { + ...item, + frequency: shouldUpdate ? currentFrequency + 1 : currentFrequency, + lastAccessedOn: shouldUpdate ? now : item.lastAccessedOn, + }; +}; + +export const trackContextAccess = (username, context) => { + if (!AccessorUtilities.canUseLocalStorage()) { + return false; + } + + const storageKey = `${username}/frequent-${context.namespace}`; + const storedRawItems = localStorage.getItem(storageKey); + const storedItems = storedRawItems ? JSON.parse(storedRawItems) : []; + const existingItemIndex = storedItems.findIndex( + (cachedItem) => cachedItem.id === context.item.id, + ); + + if (existingItemIndex > -1) { + storedItems[existingItemIndex] = updateItemAccess(storedItems[existingItemIndex]); + } else { + const newItem = updateItemAccess(context.item); + if (storedItems.length === FREQUENT_ITEMS.MAX_COUNT) { + sortItemsByFrequencyAndLastAccess(storedItems); + storedItems.pop(); + } + storedItems.push(newItem); + } + + return localStorage.setItem(storageKey, JSON.stringify(storedItems)); +}; + +export const formatContextSwitcherItems = (items) => + items.map(({ id, name: title, namespace, avatarUrl: avatar, webUrl: link }) => ({ + id, + title, + subtitle: truncateNamespace(namespace), + avatar, + link, + })); + +export const ariaCurrent = (isActive) => (isActive ? 'page' : null); |