diff options
Diffstat (limited to 'app/assets/javascripts/nav')
-rw-r--r-- | app/assets/javascripts/nav/components/top_nav_app.vue | 59 | ||||
-rw-r--r-- | app/assets/javascripts/nav/components/top_nav_container_view.vue | 74 | ||||
-rw-r--r-- | app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue | 144 | ||||
-rw-r--r-- | app/assets/javascripts/nav/components/top_nav_menu_item.vue | 31 | ||||
-rw-r--r-- | app/assets/javascripts/nav/index.js | 12 | ||||
-rw-r--r-- | app/assets/javascripts/nav/mount.js | 23 | ||||
-rw-r--r-- | app/assets/javascripts/nav/stores/index.js | 4 |
7 files changed, 347 insertions, 0 deletions
diff --git a/app/assets/javascripts/nav/components/top_nav_app.vue b/app/assets/javascripts/nav/components/top_nav_app.vue new file mode 100644 index 00000000000..f8f3ba26536 --- /dev/null +++ b/app/assets/javascripts/nav/components/top_nav_app.vue @@ -0,0 +1,59 @@ +<script> +import { GlNav, GlNavItemDropdown, GlDropdownForm, GlTooltip } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import TopNavDropdownMenu from './top_nav_dropdown_menu.vue'; + +const TOOLTIP = s__('TopNav|Switch to...'); + +export default { + components: { + GlNav, + GlNavItemDropdown, + GlDropdownForm, + GlTooltip, + TopNavDropdownMenu, + }, + props: { + navData: { + type: Object, + required: true, + }, + }, + methods: { + findTooltipTarget() { + // ### Why use a target function instead of `v-gl-tooltip`? + // To get the tooltip to align correctly, we need it to target the actual + // toggle button which we don't directly render. + return this.$el.querySelector('.js-top-nav-dropdown-toggle'); + }, + }, + TOOLTIP, +}; +</script> + +<template> + <gl-nav class="navbar-sub-nav"> + <gl-nav-item-dropdown + :text="navData.activeTitle" + icon="dot-grid" + menu-class="gl-mt-3! gl-max-w-none! gl-max-h-none! gl-sm-w-auto!" + toggle-class="top-nav-toggle js-top-nav-dropdown-toggle gl-px-3!" + no-flip + > + <gl-dropdown-form> + <top-nav-dropdown-menu + :primary="navData.primary" + :secondary="navData.secondary" + :views="navData.views" + /> + </gl-dropdown-form> + </gl-nav-item-dropdown> + <gl-tooltip + boundary="window" + :boundary-padding="0" + :target="findTooltipTarget" + placement="right" + :title="$options.TOOLTIP" + /> + </gl-nav> +</template> diff --git a/app/assets/javascripts/nav/components/top_nav_container_view.vue b/app/assets/javascripts/nav/components/top_nav_container_view.vue new file mode 100644 index 00000000000..21ff3ebcd7d --- /dev/null +++ b/app/assets/javascripts/nav/components/top_nav_container_view.vue @@ -0,0 +1,74 @@ +<script> +import FrequentItemsApp from '~/frequent_items/components/app.vue'; +import eventHub from '~/frequent_items/event_hub'; +import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue'; +import TopNavMenuItem from './top_nav_menu_item.vue'; + +export default { + components: { + FrequentItemsApp, + TopNavMenuItem, + VuexModuleProvider, + }, + props: { + frequentItemsVuexModule: { + type: String, + required: true, + }, + frequentItemsDropdownType: { + type: String, + required: true, + }, + linksPrimary: { + type: Array, + required: false, + default: () => [], + }, + linksSecondary: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + linkGroups() { + return [ + { key: 'primary', links: this.linksPrimary }, + { key: 'secondary', links: this.linksSecondary }, + ].filter((x) => x.links?.length); + }, + }, + mounted() { + // For historic reasons, the frequent-items-app component requires this too start up. + this.$nextTick(() => { + eventHub.$emit(`${this.frequentItemsDropdownType}-dropdownOpen`); + }); + }, +}; +</script> + +<template> + <div class="top-nav-container-view gl-display-flex gl-flex-direction-column"> + <div class="frequent-items-dropdown-container gl-w-auto"> + <div class="frequent-items-dropdown-content gl-w-full! gl-pt-0!"> + <vuex-module-provider :vuex-module="frequentItemsVuexModule"> + <frequent-items-app v-bind="$attrs" /> + </vuex-module-provider> + </div> + </div> + <div + v-for="({ key, links }, groupIndex) in linkGroups" + :key="key" + :class="{ 'gl-mt-3': groupIndex !== 0 }" + class="gl-mt-auto gl-pt-3 gl-border-1 gl-border-t-solid gl-border-gray-100" + data-testid="menu-item-group" + > + <top-nav-menu-item + v-for="(link, linkIndex) in links" + :key="link.title" + :menu-item="link" + :class="{ 'gl-mt-1': linkIndex !== 0 }" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue b/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue new file mode 100644 index 00000000000..1cbd64b501d --- /dev/null +++ b/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue @@ -0,0 +1,144 @@ +<script> +import { FREQUENT_ITEMS_PROJECTS, FREQUENT_ITEMS_GROUPS } from '~/frequent_items/constants'; +import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue'; +import TopNavContainerView from './top_nav_container_view.vue'; +import TopNavMenuItem from './top_nav_menu_item.vue'; + +const ACTIVE_CLASS = 'gl-shadow-none! gl-font-weight-bold! active'; +const SECONDARY_GROUP_CLASS = 'gl-pt-3 gl-mt-3 gl-border-1 gl-border-t-solid gl-border-gray-100'; + +export default { + components: { + KeepAliveSlots, + TopNavContainerView, + TopNavMenuItem, + }, + props: { + primary: { + type: Array, + required: false, + default: () => [], + }, + secondary: { + type: Array, + required: false, + default: () => [], + }, + views: { + type: Object, + required: false, + default: () => ({}), + }, + }, + data() { + return { + activeId: '', + }; + }, + computed: { + menuItemGroups() { + return [ + { key: 'primary', items: this.primary, classes: '' }, + { + key: 'secondary', + items: this.secondary, + classes: SECONDARY_GROUP_CLASS, + }, + ].filter((x) => x.items?.length); + }, + allMenuItems() { + return this.menuItemGroups.flatMap((x) => x.items); + }, + activeMenuItem() { + return this.allMenuItems.find((x) => x.id === this.activeId); + }, + activeView() { + return this.activeMenuItem?.view; + }, + menuClass() { + if (!this.activeView) { + return 'gl-w-full'; + } + + return ''; + }, + }, + created() { + // Initialize activeId based on initialization prop + this.activeId = this.allMenuItems.find((x) => x.active)?.id; + }, + methods: { + onClick({ id, href }) { + // If we're a link, let's just do the default behavior so the view won't change + if (href) { + return; + } + + this.activeId = id; + }, + menuItemClasses(menuItem) { + if (menuItem.id === this.activeId) { + return ACTIVE_CLASS; + } + + return ''; + }, + }, + FREQUENT_ITEMS_PROJECTS, + FREQUENT_ITEMS_GROUPS, + // expose for unit tests + ACTIVE_CLASS, + SECONDARY_GROUP_CLASS, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-stretch"> + <div + class="gl-w-grid-size-30 gl-flex-shrink-0 gl-bg-gray-10" + :class="menuClass" + data-testid="menu-sidebar" + > + <div + class="gl-py-3 gl-px-5 gl-h-full gl-display-flex gl-align-items-stretch gl-flex-direction-column" + > + <div + v-for="group in menuItemGroups" + :key="group.key" + :class="group.classes" + data-testid="menu-item-group" + > + <top-nav-menu-item + v-for="(menu, index) in group.items" + :key="menu.id" + data-testid="menu-item" + :class="[{ 'gl-mt-1': index !== 0 }, menuItemClasses(menu)]" + :menu-item="menu" + @click="onClick(menu)" + /> + </div> + </div> + </div> + <keep-alive-slots + v-show="activeView" + :slot-key="activeView" + class="gl-w-grid-size-40 gl-overflow-hidden gl-py-3 gl-px-5" + data-testid="menu-subview" + > + <template #projects> + <top-nav-container-view + :frequent-items-dropdown-type="$options.FREQUENT_ITEMS_PROJECTS.namespace" + :frequent-items-vuex-module="$options.FREQUENT_ITEMS_PROJECTS.vuexModule" + v-bind="views.projects" + /> + </template> + <template #groups> + <top-nav-container-view + :frequent-items-dropdown-type="$options.FREQUENT_ITEMS_GROUPS.namespace" + :frequent-items-vuex-module="$options.FREQUENT_ITEMS_GROUPS.vuexModule" + v-bind="views.groups" + /> + </template> + </keep-alive-slots> + </div> +</template> diff --git a/app/assets/javascripts/nav/components/top_nav_menu_item.vue b/app/assets/javascripts/nav/components/top_nav_menu_item.vue new file mode 100644 index 00000000000..a0d92811a6f --- /dev/null +++ b/app/assets/javascripts/nav/components/top_nav_menu_item.vue @@ -0,0 +1,31 @@ +<script> +import { GlButton, GlIcon } from '@gitlab/ui'; + +export default { + components: { + GlButton, + GlIcon, + }, + props: { + menuItem: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <gl-button + category="tertiary" + :href="menuItem.href" + class="top-nav-menu-item gl-display-block" + v-on="$listeners" + > + <span class="gl-display-flex"> + <gl-icon v-if="menuItem.icon" :name="menuItem.icon" class="gl-mr-2!" /> + {{ menuItem.title }} + <gl-icon v-if="menuItem.view" name="chevron-right" class="gl-ml-auto" /> + </span> + </gl-button> +</template> diff --git a/app/assets/javascripts/nav/index.js b/app/assets/javascripts/nav/index.js new file mode 100644 index 00000000000..646ce3f0ecf --- /dev/null +++ b/app/assets/javascripts/nav/index.js @@ -0,0 +1,12 @@ +export const initTopNav = async () => { + const el = document.getElementById('js-top-nav'); + + if (!el) { + return; + } + + // With combined_menu feature flag, there's a benefit to splitting up the import + const { mountTopNav } = await import(/* webpackChunkName: 'top_nav' */ './mount'); + + mountTopNav(el); +}; diff --git a/app/assets/javascripts/nav/mount.js b/app/assets/javascripts/nav/mount.js new file mode 100644 index 00000000000..0d46ff56249 --- /dev/null +++ b/app/assets/javascripts/nav/mount.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import App from './components/top_nav_app.vue'; +import { createStore } from './stores'; + +Vue.use(Vuex); + +export const mountTopNav = (el) => { + const viewModel = JSON.parse(el.dataset.viewModel); + const store = createStore(); + + return new Vue({ + el, + store, + render(h) { + return h(App, { + props: { + navData: viewModel, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/nav/stores/index.js b/app/assets/javascripts/nav/stores/index.js new file mode 100644 index 00000000000..527bbdd5c3f --- /dev/null +++ b/app/assets/javascripts/nav/stores/index.js @@ -0,0 +1,4 @@ +import Vuex from 'vuex'; +import { createStoreOptions } from '~/frequent_items/store'; + +export const createStore = () => new Vuex.Store(createStoreOptions()); |