diff options
author | Dennis Tang <dennis@dennistang.net> | 2018-07-06 16:40:11 +0300 |
---|---|---|
committer | Phil Hughes <me@iamphill.com> | 2018-07-06 16:40:11 +0300 |
commit | 3892b022e3173851f418e4bd8469f0dcdde2ebef (patch) | |
tree | 4379c1214ca409902e0d858551282e2dd0c262aa /app | |
parent | b14b31b819f0f09d73e001a80acd528aad913dc9 (diff) |
Resolve "Add dropdown to Groups link in top bar"
Diffstat (limited to 'app')
31 files changed, 830 insertions, 765 deletions
diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue new file mode 100644 index 00000000000..2f030de8967 --- /dev/null +++ b/app/assets/javascripts/frequent_items/components/app.vue @@ -0,0 +1,122 @@ +<script> +import { mapState, mapActions, mapGetters } from 'vuex'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; +import AccessorUtilities from '~/lib/utils/accessor'; +import eventHub from '../event_hub'; +import store from '../store/'; +import { FREQUENT_ITEMS, STORAGE_KEY } from '../constants'; +import { isMobile, updateExistingFrequentItem } from '../utils'; +import FrequentItemsSearchInput from './frequent_items_search_input.vue'; +import FrequentItemsList from './frequent_items_list.vue'; +import frequentItemsMixin from './frequent_items_mixin'; + +export default { + store, + components: { + LoadingIcon, + FrequentItemsSearchInput, + FrequentItemsList, + }, + mixins: [frequentItemsMixin], + props: { + currentUserName: { + type: String, + required: true, + }, + currentItem: { + type: Object, + required: true, + }, + }, + computed: { + ...mapState(['searchQuery', 'isLoadingItems', 'isFetchFailed', 'items']), + ...mapGetters(['hasSearchQuery']), + translations() { + return this.getTranslations(['loadingMessage', 'header']); + }, + }, + created() { + const { namespace, currentUserName, currentItem } = this; + const storageKey = `${currentUserName}/${STORAGE_KEY[namespace]}`; + + this.setNamespace(namespace); + this.setStorageKey(storageKey); + + if (currentItem.id) { + this.logItemAccess(storageKey, currentItem); + } + + eventHub.$on(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler); + }, + beforeDestroy() { + eventHub.$off(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler); + }, + methods: { + ...mapActions(['setNamespace', 'setStorageKey', 'fetchFrequentItems']), + dropdownOpenHandler() { + if (this.searchQuery === '' || isMobile()) { + this.fetchFrequentItems(); + } + }, + logItemAccess(storageKey, item) { + if (!AccessorUtilities.isLocalStorageAccessSafe()) { + return false; + } + + // Check if there's any frequent items list set + const storedRawItems = localStorage.getItem(storageKey); + const storedFrequentItems = storedRawItems + ? JSON.parse(storedRawItems) + : [{ ...item, frequency: 1 }]; // No frequent items list set, set one up. + + // Check if item already exists in list + const itemMatchIndex = storedFrequentItems.findIndex( + frequentItem => frequentItem.id === item.id, + ); + + if (itemMatchIndex > -1) { + storedFrequentItems[itemMatchIndex] = updateExistingFrequentItem( + storedFrequentItems[itemMatchIndex], + item, + ); + } else { + if (storedFrequentItems.length === FREQUENT_ITEMS.MAX_COUNT) { + storedFrequentItems.shift(); + } + + storedFrequentItems.push({ ...item, frequency: 1 }); + } + + return localStorage.setItem(storageKey, JSON.stringify(storedFrequentItems)); + }, + }, +}; +</script> + +<template> + <div> + <frequent-items-search-input + :namespace="namespace" + /> + <loading-icon + v-if="isLoadingItems" + :label="translations.loadingMessage" + class="loading-animation prepend-top-20" + size="2" + /> + <div + v-if="!isLoadingItems && !hasSearchQuery" + class="section-header" + > + {{ translations.header }} + </div> + <frequent-items-list + v-if="!isLoadingItems" + :items="items" + :namespace="namespace" + :has-search-query="hasSearchQuery" + :is-fetch-failed="isFetchFailed" + :matcher="searchQuery" + /> + </div> +</template> diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list.vue new file mode 100644 index 00000000000..8e511aa2a36 --- /dev/null +++ b/app/assets/javascripts/frequent_items/components/frequent_items_list.vue @@ -0,0 +1,78 @@ +<script> +import FrequentItemsListItem from './frequent_items_list_item.vue'; +import frequentItemsMixin from './frequent_items_mixin'; + +export default { + components: { + FrequentItemsListItem, + }, + mixins: [frequentItemsMixin], + props: { + items: { + type: Array, + required: true, + }, + hasSearchQuery: { + type: Boolean, + required: true, + }, + isFetchFailed: { + type: Boolean, + required: true, + }, + matcher: { + type: String, + required: true, + }, + }, + computed: { + translations() { + return this.getTranslations([ + 'itemListEmptyMessage', + 'itemListErrorMessage', + 'searchListEmptyMessage', + 'searchListErrorMessage', + ]); + }, + isListEmpty() { + return this.items.length === 0; + }, + listEmptyMessage() { + if (this.hasSearchQuery) { + return this.isFetchFailed + ? this.translations.searchListErrorMessage + : this.translations.searchListEmptyMessage; + } + + return this.isFetchFailed + ? this.translations.itemListErrorMessage + : this.translations.itemListEmptyMessage; + }, + }, +}; +</script> + +<template> + <div class="frequent-items-list-container"> + <ul class="list-unstyled"> + <li + v-if="isListEmpty" + :class="{ 'section-failure': isFetchFailed }" + class="section-empty" + > + {{ listEmptyMessage }} + </li> + <frequent-items-list-item + v-for="item in items" + v-else + :key="item.id" + :item-id="item.id" + :item-name="item.name" + :namespace="item.namespace" + :web-url="item.webUrl" + :avatar-url="item.avatarUrl" + :matcher="matcher" + /> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue new file mode 100644 index 00000000000..1f1665ff7fe --- /dev/null +++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue @@ -0,0 +1,117 @@ +<script> +/* eslint-disable vue/require-default-prop, vue/require-prop-types */ +import Identicon from '../../vue_shared/components/identicon.vue'; + +export default { + components: { + Identicon, + }, + props: { + matcher: { + type: String, + required: false, + }, + itemId: { + type: Number, + required: true, + }, + itemName: { + type: String, + required: true, + }, + namespace: { + type: String, + required: false, + }, + webUrl: { + type: String, + required: true, + }, + avatarUrl: { + required: true, + validator(value) { + return value === null || typeof value === 'string'; + }, + }, + }, + computed: { + hasAvatar() { + return this.avatarUrl !== null; + }, + highlightedItemName() { + if (this.matcher) { + const matcherRegEx = new RegExp(this.matcher, 'gi'); + const matches = this.itemName.match(matcherRegEx); + + if (matches && matches.length > 0) { + return this.itemName.replace(matches[0], `<b>${matches[0]}</b>`); + } + } + return this.itemName; + }, + /** + * Smartly truncates item namespace by doing two things; + * 1. Only include Group names in path by removing item name + * 2. Only include first and last group names in the path + * when namespace has more than 2 groups present + * + * First part (removal of item name from namespace) can be + * done from backend but doing so involves migration of + * existing item namespaces which is not wise thing to do. + */ + truncatedNamespace() { + if (!this.namespace) { + return null; + } + const namespaceArr = this.namespace.split(' / '); + + namespaceArr.splice(-1, 1); + let namespace = namespaceArr.join(' / '); + + if (namespaceArr.length > 2) { + namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`; + } + + return namespace; + }, + }, +}; +</script> + +<template> + <li class="frequent-items-list-item-container"> + <a + :href="webUrl" + class="clearfix" + > + <div class="frequent-items-item-avatar-container"> + <img + v-if="hasAvatar" + :src="avatarUrl" + class="avatar s32" + /> + <identicon + v-else + :entity-id="itemId" + :entity-name="itemName" + size-class="s32" + /> + </div> + <div class="frequent-items-item-metadata-container"> + <div + :title="itemName" + class="frequent-items-item-title" + v-html="highlightedItemName" + > + </div> + <div + v-if="truncatedNamespace" + :title="namespace" + class="frequent-items-item-namespace" + > + {{ truncatedNamespace }} + </div> + </div> + </a> + </li> +</template> diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_mixin.js b/app/assets/javascripts/frequent_items/components/frequent_items_mixin.js new file mode 100644 index 00000000000..704dc83ca8e --- /dev/null +++ b/app/assets/javascripts/frequent_items/components/frequent_items_mixin.js @@ -0,0 +1,23 @@ +import { TRANSLATION_KEYS } from '../constants'; + +export default { + props: { + namespace: { + type: String, + required: true, + }, + }, + methods: { + getTranslations(keys) { + const translationStrings = keys.reduce( + (acc, key) => ({ + ...acc, + [key]: TRANSLATION_KEYS[this.namespace][key], + }), + {}, + ); + + return translationStrings; + }, + }, +}; diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue new file mode 100644 index 00000000000..a6a265eb3fd --- /dev/null +++ b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue @@ -0,0 +1,55 @@ +<script> +import _ from 'underscore'; +import { mapActions } from 'vuex'; +import eventHub from '../event_hub'; +import frequentItemsMixin from './frequent_items_mixin'; + +export default { + mixins: [frequentItemsMixin], + data() { + return { + searchQuery: '', + }; + }, + computed: { + translations() { + return this.getTranslations(['searchInputPlaceholder']); + }, + }, + watch: { + searchQuery: _.debounce(function debounceSearchQuery() { + this.setSearchQuery(this.searchQuery); + }, 500), + }, + mounted() { + eventHub.$on(`${this.namespace}-dropdownOpen`, this.setFocus); + }, + beforeDestroy() { + eventHub.$off(`${this.namespace}-dropdownOpen`, this.setFocus); + }, + methods: { + ...mapActions(['setSearchQuery']), + setFocus() { + this.$refs.search.focus(); + }, + }, +}; +</script> + +<template> + <div class="search-input-container d-none d-sm-block"> + <input + ref="search" + v-model="searchQuery" + :placeholder="translations.searchInputPlaceholder" + type="search" + class="form-control" + /> + <i + v-if="!searchQuery" + class="search-icon fa fa-fw fa-search" + aria-hidden="true" + > + </i> + </div> +</template> diff --git a/app/assets/javascripts/frequent_items/constants.js b/app/assets/javascripts/frequent_items/constants.js new file mode 100644 index 00000000000..9bc17f5ef4f --- /dev/null +++ b/app/assets/javascripts/frequent_items/constants.js @@ -0,0 +1,38 @@ +import { s__ } from '~/locale'; + +export const FREQUENT_ITEMS = { + MAX_COUNT: 20, + LIST_COUNT_DESKTOP: 5, + LIST_COUNT_MOBILE: 3, + ELIGIBLE_FREQUENCY: 3, +}; + +export const HOUR_IN_MS = 3600000; + +export const STORAGE_KEY = { + projects: 'frequent-projects', + groups: 'frequent-groups', +}; + +export const TRANSLATION_KEYS = { + projects: { + loadingMessage: s__('ProjectsDropdown|Loading projects'), + header: s__('ProjectsDropdown|Frequently visited'), + itemListErrorMessage: s__( + 'ProjectsDropdown|This feature requires browser localStorage support', + ), + itemListEmptyMessage: s__('ProjectsDropdown|Projects you visit often will appear here'), + searchListErrorMessage: s__('ProjectsDropdown|Something went wrong on our end.'), + searchListEmptyMessage: s__('ProjectsDropdown|Sorry, no projects matched your search'), + searchInputPlaceholder: s__('ProjectsDropdown|Search your projects'), + }, + groups: { + loadingMessage: s__('GroupsDropdown|Loading groups'), + header: s__('GroupsDropdown|Frequently visited'), + itemListErrorMessage: s__('GroupsDropdown|This feature requires browser localStorage support'), + itemListEmptyMessage: s__('GroupsDropdown|Groups you visit often will appear here'), + searchListErrorMessage: s__('GroupsDropdown|Something went wrong on our end.'), + searchListEmptyMessage: s__('GroupsDropdown|Sorry, no groups matched your search'), + searchInputPlaceholder: s__('GroupsDropdown|Search your groups'), + }, +}; diff --git a/app/assets/javascripts/projects_dropdown/event_hub.js b/app/assets/javascripts/frequent_items/event_hub.js index 0948c2e5352..0948c2e5352 100644 --- a/app/assets/javascripts/projects_dropdown/event_hub.js +++ b/app/assets/javascripts/frequent_items/event_hub.js diff --git a/app/assets/javascripts/frequent_items/index.js b/app/assets/javascripts/frequent_items/index.js new file mode 100644 index 00000000000..5157ff211dc --- /dev/null +++ b/app/assets/javascripts/frequent_items/index.js @@ -0,0 +1,69 @@ +import $ from 'jquery'; +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import eventHub from '~/frequent_items/event_hub'; +import frequentItems from './components/app.vue'; + +Vue.use(Translate); + +const frequentItemDropdowns = [ + { + namespace: 'projects', + key: 'project', + }, + { + namespace: 'groups', + key: 'group', + }, +]; + +document.addEventListener('DOMContentLoaded', () => { + frequentItemDropdowns.forEach(dropdown => { + const { namespace, key } = dropdown; + const el = document.getElementById(`js-${namespace}-dropdown`); + const navEl = document.getElementById(`nav-${namespace}-dropdown`); + + // Don't do anything if element doesn't exist (No groups dropdown) + // This is for when the user accesses GitLab without logging in + if (!el || !navEl) { + return; + } + + $(navEl).on('shown.bs.dropdown', () => { + eventHub.$emit(`${namespace}-dropdownOpen`); + }); + + // eslint-disable-next-line no-new + new Vue({ + el, + components: { + frequentItems, + }, + data() { + const { dataset } = this.$options.el; + const item = { + id: Number(dataset[`${key}Id`]), + name: dataset[`${key}Name`], + namespace: dataset[`${key}Namespace`], + webUrl: dataset[`${key}WebUrl`], + avatarUrl: dataset[`${key}AvatarUrl`] || null, + lastAccessedOn: Date.now(), + }; + + return { + currentUserName: dataset.userName, + currentItem: item, + }; + }, + render(createElement) { + return createElement('frequent-items', { + props: { + namespace, + currentUserName: this.currentUserName, + currentItem: this.currentItem, + }, + }); + }, + }); + }); +}); diff --git a/app/assets/javascripts/frequent_items/store/actions.js b/app/assets/javascripts/frequent_items/store/actions.js new file mode 100644 index 00000000000..3dd89a82a42 --- /dev/null +++ b/app/assets/javascripts/frequent_items/store/actions.js @@ -0,0 +1,81 @@ +import Api from '~/api'; +import AccessorUtilities from '~/lib/utils/accessor'; +import * as types from './mutation_types'; +import { getTopFrequentItems } from '../utils'; + +export const setNamespace = ({ commit }, namespace) => { + commit(types.SET_NAMESPACE, namespace); +}; + +export const setStorageKey = ({ commit }, key) => { + commit(types.SET_STORAGE_KEY, key); +}; + +export const requestFrequentItems = ({ commit }) => { + commit(types.REQUEST_FREQUENT_ITEMS); +}; +export const receiveFrequentItemsSuccess = ({ commit }, data) => { + commit(types.RECEIVE_FREQUENT_ITEMS_SUCCESS, data); +}; +export const receiveFrequentItemsError = ({ commit }) => { + commit(types.RECEIVE_FREQUENT_ITEMS_ERROR); +}; + +export const fetchFrequentItems = ({ state, dispatch }) => { + dispatch('requestFrequentItems'); + + if (AccessorUtilities.isLocalStorageAccessSafe()) { + const storedFrequentItems = JSON.parse(localStorage.getItem(state.storageKey)); + + dispatch( + 'receiveFrequentItemsSuccess', + !storedFrequentItems ? [] : getTopFrequentItems(storedFrequentItems), + ); + } else { + dispatch('receiveFrequentItemsError'); + } +}; + +export const requestSearchedItems = ({ commit }) => { + commit(types.REQUEST_SEARCHED_ITEMS); +}; +export const receiveSearchedItemsSuccess = ({ commit }, data) => { + commit(types.RECEIVE_SEARCHED_ITEMS_SUCCESS, data); +}; +export const receiveSearchedItemsError = ({ commit }) => { + commit(types.RECEIVE_SEARCHED_ITEMS_ERROR); +}; +export const fetchSearchedItems = ({ state, dispatch }, searchQuery) => { + dispatch('requestSearchedItems'); + + const params = { + simple: true, + per_page: 20, + membership: !!gon.current_user_id, + }; + + if (state.namespace === 'projects') { + params.order_by = 'last_activity_at'; + } + + return Api[state.namespace](searchQuery, params) + .then(results => { + dispatch('receiveSearchedItemsSuccess', results); + }) + .catch(() => { + dispatch('receiveSearchedItemsError'); + }); +}; + +export const setSearchQuery = ({ commit, dispatch }, query) => { + commit(types.SET_SEARCH_QUERY, query); + + if (query) { + dispatch('fetchSearchedItems', query); + } else { + dispatch('fetchFrequentItems'); + } +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/frequent_items/store/getters.js b/app/assets/javascripts/frequent_items/store/getters.js new file mode 100644 index 00000000000..00165db6684 --- /dev/null +++ b/app/assets/javascripts/frequent_items/store/getters.js @@ -0,0 +1,4 @@ +export const hasSearchQuery = state => state.searchQuery !== ''; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/frequent_items/store/index.js b/app/assets/javascripts/frequent_items/store/index.js new file mode 100644 index 00000000000..ece9e6419dd --- /dev/null +++ b/app/assets/javascripts/frequent_items/store/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export default () => + new Vuex.Store({ + actions, + getters, + mutations, + state: state(), + }); diff --git a/app/assets/javascripts/frequent_items/store/mutation_types.js b/app/assets/javascripts/frequent_items/store/mutation_types.js new file mode 100644 index 00000000000..cbe2c9401ad --- /dev/null +++ b/app/assets/javascripts/frequent_items/store/mutation_types.js @@ -0,0 +1,9 @@ +export const SET_NAMESPACE = 'SET_NAMESPACE'; +export const SET_STORAGE_KEY = 'SET_STORAGE_KEY'; +export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY'; +export const REQUEST_FREQUENT_ITEMS = 'REQUEST_FREQUENT_ITEMS'; +export const RECEIVE_FREQUENT_ITEMS_SUCCESS = 'RECEIVE_FREQUENT_ITEMS_SUCCESS'; +export const RECEIVE_FREQUENT_ITEMS_ERROR = 'RECEIVE_FREQUENT_ITEMS_ERROR'; +export const REQUEST_SEARCHED_ITEMS = 'REQUEST_SEARCHED_ITEMS'; +export const RECEIVE_SEARCHED_ITEMS_SUCCESS = 'RECEIVE_SEARCHED_ITEMS_SUCCESS'; +export const RECEIVE_SEARCHED_ITEMS_ERROR = 'RECEIVE_SEARCHED_ITEMS_ERROR'; diff --git a/app/assets/javascripts/frequent_items/store/mutations.js b/app/assets/javascripts/frequent_items/store/mutations.js new file mode 100644 index 00000000000..41b660a243f --- /dev/null +++ b/app/assets/javascripts/frequent_items/store/mutations.js @@ -0,0 +1,71 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_NAMESPACE](state, namespace) { + Object.assign(state, { + namespace, + }); + }, + [types.SET_STORAGE_KEY](state, storageKey) { + Object.assign(state, { + storageKey, + }); + }, + [types.SET_SEARCH_QUERY](state, searchQuery) { + const hasSearchQuery = searchQuery !== ''; + + Object.assign(state, { + searchQuery, + isLoadingItems: true, + hasSearchQuery, + }); + }, + [types.REQUEST_FREQUENT_ITEMS](state) { + Object.assign(state, { + isLoadingItems: true, + hasSearchQuery: false, + }); + }, + [types.RECEIVE_FREQUENT_ITEMS_SUCCESS](state, rawItems) { + Object.assign(state, { + items: rawItems, + isLoadingItems: false, + hasSearchQuery: false, + isFetchFailed: false, + }); + }, + [types.RECEIVE_FREQUENT_ITEMS_ERROR](state) { + Object.assign(state, { + isLoadingItems: false, + hasSearchQuery: false, + isFetchFailed: true, + }); + }, + [types.REQUEST_SEARCHED_ITEMS](state) { + Object.assign(state, { + isLoadingItems: true, + hasSearchQuery: true, + }); + }, + [types.RECEIVE_SEARCHED_ITEMS_SUCCESS](state, rawItems) { + Object.assign(state, { + items: rawItems.map(rawItem => ({ + id: rawItem.id, + name: rawItem.name, + namespace: rawItem.name_with_namespace || rawItem.full_name, + webUrl: rawItem.web_url, + avatarUrl: rawItem.avatar_url, + })), + isLoadingItems: false, + hasSearchQuery: true, + isFetchFailed: false, + }); + }, + [types.RECEIVE_SEARCHED_ITEMS_ERROR](state) { + Object.assign(state, { + isLoadingItems: false, + hasSearchQuery: true, + isFetchFailed: true, + }); + }, +}; diff --git a/app/assets/javascripts/frequent_items/store/state.js b/app/assets/javascripts/frequent_items/store/state.js new file mode 100644 index 00000000000..75b04febee4 --- /dev/null +++ b/app/assets/javascripts/frequent_items/store/state.js @@ -0,0 +1,8 @@ +export default () => ({ + namespace: '', + storageKey: '', + searchQuery: '', + isLoadingItems: false, + isFetchFailed: false, + items: [], +}); diff --git a/app/assets/javascripts/frequent_items/utils.js b/app/assets/javascripts/frequent_items/utils.js new file mode 100644 index 00000000000..aba692e4b99 --- /dev/null +++ b/app/assets/javascripts/frequent_items/utils.js @@ -0,0 +1,49 @@ +import _ from 'underscore'; +import bp from '~/breakpoints'; +import { FREQUENT_ITEMS, HOUR_IN_MS } from './constants'; + +export const isMobile = () => { + const screenSize = bp.getBreakpointSize(); + + return screenSize === 'sm' || screenSize === 'xs'; +}; + +export const getTopFrequentItems = items => { + if (!items) { + return []; + } + const frequentItemsCount = isMobile() + ? FREQUENT_ITEMS.LIST_COUNT_MOBILE + : FREQUENT_ITEMS.LIST_COUNT_DESKTOP; + + const frequentItems = items.filter(item => item.frequency >= FREQUENT_ITEMS.ELIGIBLE_FREQUENCY); + + if (!frequentItems || frequentItems.length === 0) { + return []; + } + + frequentItems.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; + }); + + return _.first(frequentItems, frequentItemsCount); +}; + +export const updateExistingFrequentItem = (frequentItem, item) => { + const accessedOverHourAgo = + Math.abs(item.lastAccessedOn - frequentItem.lastAccessedOn) / HOUR_IN_MS > 1; + + return { + ...item, + frequency: accessedOverHourAgo ? frequentItem.frequency + 1 : frequentItem.frequency, + lastAccessedOn: accessedOverHourAgo ? Date.now() : frequentItem.lastAccessedOn, + }; +}; diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index c9ce838cd48..2718f73a830 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -26,7 +26,7 @@ import './feature_highlight/feature_highlight_options'; import LazyLoader from './lazy_loader'; import initLogoAnimation from './logo'; import './milestone_select'; -import './projects_dropdown'; +import './frequent_items'; import initBreadcrumbs from './breadcrumb'; import initDispatcher from './dispatcher'; diff --git a/app/assets/javascripts/projects_dropdown/components/app.vue b/app/assets/javascripts/projects_dropdown/components/app.vue deleted file mode 100644 index 73d49488299..00000000000 --- a/app/assets/javascripts/projects_dropdown/components/app.vue +++ /dev/null @@ -1,158 +0,0 @@ -<script> -import bs from '../../breakpoints'; -import eventHub from '../event_hub'; -import loadingIcon from '../../vue_shared/components/loading_icon.vue'; - -import projectsListFrequent from './projects_list_frequent.vue'; -import projectsListSearch from './projects_list_search.vue'; - -import search from './search.vue'; - -export default { - components: { - search, - loadingIcon, - projectsListFrequent, - projectsListSearch, - }, - props: { - currentProject: { - type: Object, - required: true, - }, - store: { - type: Object, - required: true, - }, - service: { - type: Object, - required: true, - }, - }, - data() { - return { - isLoadingProjects: false, - isFrequentsListVisible: false, - isSearchListVisible: false, - isLocalStorageFailed: false, - isSearchFailed: false, - searchQuery: '', - }; - }, - computed: { - frequentProjects() { - return this.store.getFrequentProjects(); - }, - searchProjects() { - return this.store.getSearchedProjects(); - }, - }, - created() { - if (this.currentProject.id) { - this.logCurrentProjectAccess(); - } - - eventHub.$on('dropdownOpen', this.fetchFrequentProjects); - eventHub.$on('searchProjects', this.fetchSearchedProjects); - eventHub.$on('searchCleared', this.handleSearchClear); - eventHub.$on('searchFailed', this.handleSearchFailure); - }, - beforeDestroy() { - eventHub.$off('dropdownOpen', this.fetchFrequentProjects); - eventHub.$off('searchProjects', this.fetchSearchedProjects); - eventHub.$off('searchCleared', this.handleSearchClear); - eventHub.$off('searchFailed', this.handleSearchFailure); - }, - methods: { - toggleFrequentProjectsList(state) { - this.isLoadingProjects = !state; - this.isSearchListVisible = !state; - this.isFrequentsListVisible = state; - }, - toggleSearchProjectsList(state) { - this.isLoadingProjects = !state; - this.isFrequentsListVisible = !state; - this.isSearchListVisible = state; - }, - toggleLoader(state) { - this.isFrequentsListVisible = !state; - this.isSearchListVisible = !state; - this.isLoadingProjects = state; - }, - fetchFrequentProjects() { - const screenSize = bs.getBreakpointSize(); - if (this.searchQuery && (screenSize !== 'sm' && screenSize !== 'xs')) { - this.toggleSearchProjectsList(true); - } else { - this.toggleLoader(true); - this.isLocalStorageFailed = false; - const projects = this.service.getFrequentProjects(); - if (projects) { - this.toggleFrequentProjectsList(true); - this.store.setFrequentProjects(projects); - } else { - this.isLocalStorageFailed = true; - this.toggleFrequentProjectsList(true); - this.store.setFrequentProjects([]); - } - } - }, - fetchSearchedProjects(searchQuery) { - this.searchQuery = searchQuery; - this.toggleLoader(true); - this.service - .getSearchedProjects(this.searchQuery) - .then(res => res.json()) - .then(results => { - this.toggleSearchProjectsList(true); - this.store.setSearchedProjects(results); - }) - .catch(() => { - this.isSearchFailed = true; - this.toggleSearchProjectsList(true); - }); - }, - logCurrentProjectAccess() { - this.service.logProjectAccess(this.currentProject); - }, - handleSearchClear() { - this.searchQuery = ''; - this.toggleFrequentProjectsList(true); - this.store.clearSearchedProjects(); - }, - handleSearchFailure() { - this.isSearchFailed = true; - this.toggleSearchProjectsList(true); - }, - }, -}; -</script> - -<template> - <div> - <search/> - <loading-icon - v-if="isLoadingProjects" - :label="s__('ProjectsDropdown|Loading projects')" - class="loading-animation prepend-top-20" - size="2" - /> - <div - v-if="isFrequentsListVisible" - class="section-header" - > - {{ s__('ProjectsDropdown|Frequently visited') }} - </div> - <projects-list-frequent - v-if="isFrequentsListVisible" - :local-storage-failed="isLocalStorageFailed" - :projects="frequentProjects" - /> - <projects-list-search - v-if="isSearchListVisible" - :search-failed="isSearchFailed" - :matcher="searchQuery" - :projects="searchProjects" - /> - </div> -</template> diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue deleted file mode 100644 index 625e0aa548c..00000000000 --- a/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue +++ /dev/null @@ -1,57 +0,0 @@ -<script> - import { s__ } from '../../locale'; - import projectsListItem from './projects_list_item.vue'; - - export default { - components: { - projectsListItem, - }, - props: { - projects: { - type: Array, - required: true, - }, - localStorageFailed: { - type: Boolean, - required: true, - }, - }, - computed: { - isListEmpty() { - return this.projects.length === 0; - }, - listEmptyMessage() { - return this.localStorageFailed ? - s__('ProjectsDropdown|This feature requires browser localStorage support') : - s__('ProjectsDropdown|Projects you visit often will appear here'); - }, - }, - }; -</script> - -<template> - <div - class="projects-list-frequent-container" - > - <ul - class="list-unstyled" - > - <li - v-if="isListEmpty" - class="section-empty" - > - {{ listEmptyMessage }} - </li> - <projects-list-item - v-for="(project, index) in projects" - v-else - :key="index" - :project-id="project.id" - :project-name="project.name" - :namespace="project.namespace" - :web-url="project.webUrl" - :avatar-url="project.avatarUrl" - /> - </ul> - </div> -</template> diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue deleted file mode 100644 index eafbf6c99e2..00000000000 --- a/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue +++ /dev/null @@ -1,116 +0,0 @@ -<script> - /* eslint-disable vue/require-default-prop, vue/require-prop-types */ - import identicon from '../../vue_shared/components/identicon.vue'; - - export default { - components: { - identicon, - }, - props: { - matcher: { - type: String, - required: false, - }, - projectId: { - type: Number, - required: true, - }, - projectName: { - type: String, - required: true, - }, - namespace: { - type: String, - required: true, - }, - webUrl: { - type: String, - required: true, - }, - avatarUrl: { - required: true, - validator(value) { - return value === null || typeof value === 'string'; - }, - }, - }, - computed: { - hasAvatar() { - return this.avatarUrl !== null; - }, - highlightedProjectName() { - if (this.matcher) { - const matcherRegEx = new RegExp(this.matcher, 'gi'); - const matches = this.projectName.match(matcherRegEx); - - if (matches && matches.length > 0) { - return this.projectName.replace(matches[0], `<b>${matches[0]}</b>`); - } - } - return this.projectName; - }, - /** - * Smartly truncates project namespace by doing two things; - * 1. Only include Group names in path by removing project name - * 2. Only include first and last group names in the path - * when namespace has more than 2 groups present - * - * First part (removal of project name from namespace) can be - * done from backend but doing so involves migration of - * existing project namespaces which is not wise thing to do. - */ - truncatedNamespace() { - const namespaceArr = this.namespace.split(' / '); - namespaceArr.splice(-1, 1); - let namespace = namespaceArr.join(' / '); - - if (namespaceArr.length > 2) { - namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`; - } - - return namespace; - }, - }, - }; -</script> - -<template> - <li - class="projects-list-item-container" - > - <a - :href="webUrl" - class="clearfix" - > - <div - class="project-item-avatar-container" - > - <img - v-if="hasAvatar" - :src="avatarUrl" - class="avatar s32" - /> - <identicon - v-else - :entity-id="projectId" - :entity-name="projectName" - size-class="s32" - /> - </div> - <div - class="project-item-metadata-container" - > - <div - :title="projectName" - class="project-title" - v-html="highlightedProjectName" - > - </div> - <div - :title="namespace" - class="project-namespace" - >{{ truncatedNamespace }}</div> - </div> - </a> - </li> -</template> diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue deleted file mode 100644 index 76e9cb9e53f..00000000000 --- a/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue +++ /dev/null @@ -1,63 +0,0 @@ -<script> -import { s__ } from '../../locale'; -import projectsListItem from './projects_list_item.vue'; - -export default { - components: { - projectsListItem, - }, - props: { - matcher: { - type: String, - required: true, - }, - projects: { - type: Array, - required: true, - }, - searchFailed: { - type: Boolean, - required: true, - }, - }, - computed: { - isListEmpty() { - return this.projects.length === 0; - }, - listEmptyMessage() { - return this.searchFailed ? - s__('ProjectsDropdown|Something went wrong on our end.') : - s__('ProjectsDropdown|Sorry, no projects matched your search'); - }, - }, -}; -</script> - -<template> - <div - class="projects-list-search-container" - > - <ul - class="list-unstyled" - > - <li - v-if="isListEmpty" - :class="{ 'section-failure': searchFailed }" - class="section-empty" - > - {{ listEmptyMessage }} - </li> - <projects-list-item - v-for="(project, index) in projects" - v-else - :key="index" - :project-id="project.id" - :project-name="project.name" - :namespace="project.namespace" - :web-url="project.webUrl" - :avatar-url="project.avatarUrl" - :matcher="matcher" - /> - </ul> - </div> -</template> diff --git a/app/assets/javascripts/projects_dropdown/components/search.vue b/app/assets/javascripts/projects_dropdown/components/search.vue deleted file mode 100644 index 28f2a18f2a6..00000000000 --- a/app/assets/javascripts/projects_dropdown/components/search.vue +++ /dev/null @@ -1,65 +0,0 @@ -<script> - import _ from 'underscore'; - import eventHub from '../event_hub'; - - export default { - data() { - return { - searchQuery: '', - }; - }, - watch: { - searchQuery() { - this.handleInput(); - }, - }, - mounted() { - eventHub.$on('dropdownOpen', this.setFocus); - }, - beforeDestroy() { - eventHub.$off('dropdownOpen', this.setFocus); - }, - methods: { - setFocus() { - this.$refs.search.focus(); - }, - emitSearchEvents() { - if (this.searchQuery) { - eventHub.$emit('searchProjects', this.searchQuery); - } else { - eventHub.$emit('searchCleared'); - } - }, - /** - * Callback function within _.debounce is intentionally - * kept as ES5 `function() {}` instead of ES6 `() => {}` - * as it otherwise messes up function context - * and component reference is no longer accessible via `this` - */ - // eslint-disable-next-line func-names - handleInput: _.debounce(function () { - this.emitSearchEvents(); - }, 500), - }, - }; -</script> - -<template> - <div - class="search-input-container d-none d-sm-block" - > - <input - ref="search" - v-model="searchQuery" - :placeholder="s__('ProjectsDropdown|Search your projects')" - type="search" - class="form-control" - /> - <i - v-if="!searchQuery" - class="search-icon fa fa-fw fa-search" - aria-hidden="true" - > - </i> - </div> -</template> diff --git a/app/assets/javascripts/projects_dropdown/constants.js b/app/assets/javascripts/projects_dropdown/constants.js deleted file mode 100644 index 8937097184c..00000000000 --- a/app/assets/javascripts/projects_dropdown/constants.js +++ /dev/null @@ -1,10 +0,0 @@ -export const FREQUENT_PROJECTS = { - MAX_COUNT: 20, - LIST_COUNT_DESKTOP: 5, - LIST_COUNT_MOBILE: 3, - ELIGIBLE_FREQUENCY: 3, -}; - -export const HOUR_IN_MS = 3600000; - -export const STORAGE_KEY = 'frequent-projects'; diff --git a/app/assets/javascripts/projects_dropdown/index.js b/app/assets/javascripts/projects_dropdown/index.js deleted file mode 100644 index 6056f12aa4f..00000000000 --- a/app/assets/javascripts/projects_dropdown/index.js +++ /dev/null @@ -1,66 +0,0 @@ -import $ from 'jquery'; -import Vue from 'vue'; - -import Translate from '../vue_shared/translate'; -import eventHub from './event_hub'; -import ProjectsService from './service/projects_service'; -import ProjectsStore from './store/projects_store'; - -import projectsDropdownApp from './components/app.vue'; - -Vue.use(Translate); - -document.addEventListener('DOMContentLoaded', () => { - const el = document.getElementById('js-projects-dropdown'); - const navEl = document.getElementById('nav-projects-dropdown'); - - // Don't do anything if element doesn't exist (No projects dropdown) - // This is for when the user accesses GitLab without logging in - if (!el || !navEl) { - return; - } - - $(navEl).on('shown.bs.dropdown', () => { - eventHub.$emit('dropdownOpen'); - }); - - // eslint-disable-next-line no-new - new Vue({ - el, - components: { - projectsDropdownApp, - }, - data() { - const { dataset } = this.$options.el; - const store = new ProjectsStore(); - const service = new ProjectsService(dataset.userName); - - const project = { - id: Number(dataset.projectId), - name: dataset.projectName, - namespace: dataset.projectNamespace, - webUrl: dataset.projectWebUrl, - avatarUrl: dataset.projectAvatarUrl || null, - lastAccessedOn: Date.now(), - }; - - return { - store, - service, - state: store.state, - currentUserName: dataset.userName, - currentProject: project, - }; - }, - render(createElement) { - return createElement('projects-dropdown-app', { - props: { - currentUserName: this.currentUserName, - currentProject: this.currentProject, - store: this.store, - service: this.service, - }, - }); - }, - }); -}); diff --git a/app/assets/javascripts/projects_dropdown/service/projects_service.js b/app/assets/javascripts/projects_dropdown/service/projects_service.js deleted file mode 100644 index ed1c3deead2..00000000000 --- a/app/assets/javascripts/projects_dropdown/service/projects_service.js +++ /dev/null @@ -1,137 +0,0 @@ -import _ from 'underscore'; -import Vue from 'vue'; -import VueResource from 'vue-resource'; - -import bp from '../../breakpoints'; -import Api from '../../api'; -import AccessorUtilities from '../../lib/utils/accessor'; - -import { FREQUENT_PROJECTS, HOUR_IN_MS, STORAGE_KEY } from '../constants'; - -Vue.use(VueResource); - -export default class ProjectsService { - constructor(currentUserName) { - this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); - this.currentUserName = currentUserName; - this.storageKey = `${this.currentUserName}/${STORAGE_KEY}`; - this.projectsPath = Vue.resource(Api.buildUrl(Api.projectsPath)); - } - - getSearchedProjects(searchQuery) { - return this.projectsPath.get({ - simple: true, - per_page: 20, - membership: !!gon.current_user_id, - order_by: 'last_activity_at', - search: searchQuery, - }); - } - - getFrequentProjects() { - if (this.isLocalStorageAvailable) { - return this.getTopFrequentProjects(); - } - return null; - } - - logProjectAccess(project) { - let matchFound = false; - let storedFrequentProjects; - - if (this.isLocalStorageAvailable) { - const storedRawProjects = localStorage.getItem(this.storageKey); - - // Check if there's any frequent projects list set - if (!storedRawProjects) { - // No frequent projects list set, set one up. - storedFrequentProjects = []; - storedFrequentProjects.push({ ...project, frequency: 1 }); - } else { - // Check if project is already present in frequents list - // When found, update metadata of it. - storedFrequentProjects = JSON.parse(storedRawProjects).map(projectItem => { - if (projectItem.id === project.id) { - matchFound = true; - const diff = Math.abs(project.lastAccessedOn - projectItem.lastAccessedOn) / HOUR_IN_MS; - const updatedProject = { - ...project, - frequency: projectItem.frequency, - lastAccessedOn: projectItem.lastAccessedOn, - }; - - // Check if duration since last access of this project - // is over an hour - if (diff > 1) { - return { - ...updatedProject, - frequency: updatedProject.frequency + 1, - lastAccessedOn: Date.now(), - }; - } - - return { - ...updatedProject, - }; - } - - return projectItem; - }); - - // Check whether currently logged project is present in frequents list - if (!matchFound) { - // We always keep size of frequents collection to 20 projects - // out of which only 5 projects with - // highest value of `frequency` and most recent `lastAccessedOn` - // are shown in projects dropdown - if (storedFrequentProjects.length === FREQUENT_PROJECTS.MAX_COUNT) { - storedFrequentProjects.shift(); // Remove an item from head of array - } - - storedFrequentProjects.push({ ...project, frequency: 1 }); - } - } - - localStorage.setItem(this.storageKey, JSON.stringify(storedFrequentProjects)); - } - } - - getTopFrequentProjects() { - const storedFrequentProjects = JSON.parse(localStorage.getItem(this.storageKey)); - let frequentProjectsCount = FREQUENT_PROJECTS.LIST_COUNT_DESKTOP; - - if (!storedFrequentProjects) { - return []; - } - - if (bp.getBreakpointSize() === 'sm' || bp.getBreakpointSize() === 'xs') { - frequentProjectsCount = FREQUENT_PROJECTS.LIST_COUNT_MOBILE; - } - - const frequentProjects = storedFrequentProjects.filter( - project => project.frequency >= FREQUENT_PROJECTS.ELIGIBLE_FREQUENCY, - ); - - if (!frequentProjects || frequentProjects.length === 0) { - return []; - } - - // Sort all frequent projects in decending order of frequency - // and then by lastAccessedOn with recent most first - frequentProjects.sort((projectA, projectB) => { - if (projectA.frequency < projectB.frequency) { - return 1; - } else if (projectA.frequency > projectB.frequency) { - return -1; - } else if (projectA.lastAccessedOn < projectB.lastAccessedOn) { - return 1; - } else if (projectA.lastAccessedOn > projectB.lastAccessedOn) { - return -1; - } - - return 0; - }); - - return _.first(frequentProjects, frequentProjectsCount); - } -} diff --git a/app/assets/javascripts/projects_dropdown/store/projects_store.js b/app/assets/javascripts/projects_dropdown/store/projects_store.js deleted file mode 100644 index ffefbe693f4..00000000000 --- a/app/assets/javascripts/projects_dropdown/store/projects_store.js +++ /dev/null @@ -1,33 +0,0 @@ -export default class ProjectsStore { - constructor() { - this.state = {}; - this.state.frequentProjects = []; - this.state.searchedProjects = []; - } - - setFrequentProjects(rawProjects) { - this.state.frequentProjects = rawProjects; - } - - getFrequentProjects() { - return this.state.frequentProjects; - } - - setSearchedProjects(rawProjects) { - this.state.searchedProjects = rawProjects.map(rawProject => ({ - id: rawProject.id, - name: rawProject.name, - namespace: rawProject.name_with_namespace, - webUrl: rawProject.web_url, - avatarUrl: rawProject.avatar_url, - })); - } - - getSearchedProjects() { - return this.state.searchedProjects; - } - - clearSearchedProjects() { - this.state.searchedProjects = []; - } -} diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 74475daae14..c7b5e22c33d 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -36,7 +36,7 @@ width: 100%; } - &.projects-dropdown-menu { + &.frequent-items-dropdown-menu { padding: 0; overflow-y: initial; max-height: initial; @@ -790,6 +790,7 @@ @include media-breakpoint-down(xs) { .navbar-gitlab { li.header-projects, + li.header-groups, li.header-more, li.header-new, li.header-user { @@ -813,18 +814,18 @@ } } -header.header-content .dropdown-menu.projects-dropdown-menu { +header.header-content .dropdown-menu.frequent-items-dropdown-menu { padding: 0; } -.projects-dropdown-container { +.frequent-items-dropdown-container { display: flex; flex-direction: row; width: 500px; height: 334px; - .project-dropdown-sidebar, - .project-dropdown-content { + .frequent-items-dropdown-sidebar, + .frequent-items-dropdown-content { padding: 8px 0; } @@ -832,12 +833,12 @@ header.header-content .dropdown-menu.projects-dropdown-menu { color: $almost-black; } - .project-dropdown-sidebar { + .frequent-items-dropdown-sidebar { width: 30%; border-right: 1px solid $border-color; } - .project-dropdown-content { + .frequent-items-dropdown-content { position: relative; width: 70%; } @@ -848,33 +849,35 @@ header.header-content .dropdown-menu.projects-dropdown-menu { height: auto; flex: 1; - .project-dropdown-sidebar, - .project-dropdown-content { + .frequent-items-dropdown-sidebar, + .frequent-items-dropdown-content { width: 100%; } - .project-dropdown-sidebar { + .frequent-items-dropdown-sidebar { border-bottom: 1px solid $border-color; border-right: 0; } } - .projects-list-frequent-container, - .projects-list-search-container { + .section-header, + .frequent-items-list-container li.section-empty { + padding: 0 $gl-padding; + color: $gl-text-color-secondary; + font-size: $gl-font-size; + } + + .frequent-items-list-container { padding: 8px 0; overflow-y: auto; li.section-empty.section-failure { color: $callout-danger-color; } - } - .section-header, - .projects-list-frequent-container li.section-empty, - .projects-list-search-container li.section-empty { - padding: 0 15px; - color: $gl-text-color-secondary; - font-size: $gl-font-size; + .frequent-items-list-item-container a { + display: flex; + } } .search-input-container { @@ -894,12 +897,12 @@ header.header-content .dropdown-menu.projects-dropdown-menu { margin-top: 8px; } - .projects-list-search-container { + .frequent-items-search-container { height: 284px; } @include media-breakpoint-down(xs) { - .projects-list-frequent-container { + .frequent-items-list-container { width: auto; height: auto; padding-bottom: 0; @@ -907,32 +910,38 @@ header.header-content .dropdown-menu.projects-dropdown-menu { } } -.projects-list-item-container { - .project-item-avatar-container .project-item-metadata-container { +.frequent-items-list-item-container { + .frequent-items-item-avatar-container, + .frequent-items-item-metadata-container { float: left; } - .project-title, - .project-namespace { + .frequent-items-item-metadata-container { + display: flex; + flex-direction: column; + justify-content: center; + } + + .frequent-items-item-title, + .frequent-items-item-namespace { max-width: 250px; - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } &:hover { - .project-item-avatar-container .avatar { + .frequent-items-item-avatar-container .avatar { border-color: $md-area-border; } } - .project-title { + .frequent-items-item-title { font-size: $gl-font-size; font-weight: 400; line-height: 16px; } - .project-namespace { + .frequent-items-item-namespace { margin-top: 4px; font-size: 12px; line-height: 12px; @@ -940,7 +949,7 @@ header.header-content .dropdown-menu.projects-dropdown-menu { } @include media-breakpoint-down(xs) { - .project-item-metadata-container { + .frequent-items-item-metadata-container { float: none; } } diff --git a/app/assets/stylesheets/framework/gitlab_theme.scss b/app/assets/stylesheets/framework/gitlab_theme.scss index aaa8bed3df0..dff6bce370f 100644 --- a/app/assets/stylesheets/framework/gitlab_theme.scss +++ b/app/assets/stylesheets/framework/gitlab_theme.scss @@ -29,15 +29,21 @@ .navbar-sub-nav, .navbar-nav { > li { - > a:hover, - > a:focus { - background-color: rgba($search-and-nav-links, 0.2); + > a, + > button { + &:hover, + &:focus { + background-color: rgba($search-and-nav-links, 0.2); + } } - &.active > a, - &.dropdown.show > a { - color: $nav-svg-color; - background-color: $color-alternate; + &.active, + &.dropdown.show { + > a, + > button { + color: $nav-svg-color; + background-color: $color-alternate; + } } &.line-separator { @@ -147,7 +153,6 @@ } } - // Sidebar .nav-sidebar li.active { box-shadow: inset 4px 0 0 $border-and-box-shadow; diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 8bcaf5eb6ac..2097bcebf69 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -269,14 +269,8 @@ .navbar-sub-nav, .navbar-nav { > li { - > a:hover, - > a:focus { - text-decoration: none; - outline: 0; - color: $white-light; - } - - > a { + > a, + > button { display: -webkit-flex; display: flex; align-items: center; @@ -288,6 +282,18 @@ border-radius: $border-radius-default; height: 32px; font-weight: $gl-font-weight-bold; + + &:hover, + &:focus { + text-decoration: none; + outline: 0; + color: $white-light; + } + } + + > button { + background: transparent; + border: 0; } &.line-separator { @@ -311,7 +317,7 @@ font-size: 10px; } - .project-item-select-holder { + .frequent-items-item-select-holder { display: inline; } diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index 7647e25e804..4029287fc0e 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -1,16 +1,19 @@ %ul.list-unstyled.navbar-sub-nav - if dashboard_nav_link?(:projects) = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects qa-projects-dropdown" }) do - %a{ href: "#", data: { toggle: "dropdown" } } + %button{ type: 'button', data: { toggle: "dropdown" } } Projects = sprite_icon('angle-down', css_class: 'caret-down') - .dropdown-menu.projects-dropdown-menu + .dropdown-menu.frequent-items-dropdown-menu = render "layouts/nav/projects_dropdown/show" - if dashboard_nav_link?(:groups) - = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { class: "d-none d-sm-block" }) do - = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups qa-groups-link', title: 'Groups' do + = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { id: 'nav-groups-dropdown', class: "home dropdown header-groups qa-groups-dropdown" }) do + %button{ type: 'button', data: { toggle: "dropdown" } } Groups + = sprite_icon('angle-down', css_class: 'caret-down') + .dropdown-menu.frequent-items-dropdown-menu + = render "layouts/nav/groups_dropdown/show" - if dashboard_nav_link?(:activity) = nav_link(path: 'dashboard#activity', html_options: { class: "d-none d-lg-block d-xl-block" }) do @@ -34,11 +37,6 @@ = sprite_icon('angle-down', css_class: 'caret-down') .dropdown-menu %ul - - if dashboard_nav_link?(:groups) - = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { class: "d-block d-sm-none" }) do - = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', title: 'Groups' do - Groups - - if dashboard_nav_link?(:activity) = nav_link(path: 'dashboard#activity') do = link_to activity_dashboard_path, title: 'Activity' do diff --git a/app/views/layouts/nav/groups_dropdown/_show.html.haml b/app/views/layouts/nav/groups_dropdown/_show.html.haml new file mode 100644 index 00000000000..3ce1fa6bcca --- /dev/null +++ b/app/views/layouts/nav/groups_dropdown/_show.html.haml @@ -0,0 +1,12 @@ +- group_meta = { id: @group.id, name: @group.name, namespace: @group.full_name, web_url: group_path(@group), avatar_url: @group.avatar_url } if @group&.persisted? +.frequent-items-dropdown-container + .frequent-items-dropdown-sidebar.qa-groups-dropdown-sidebar + %ul + = nav_link(path: 'dashboard/groups#index') do + = link_to dashboard_groups_path, class: 'qa-your-groups-link' do + = _('Your groups') + = nav_link(path: 'groups#explore') do + = link_to explore_groups_path do + = _('Explore groups') + .frequent-items-dropdown-content + #js-groups-dropdown{ data: { user_name: current_user.username, group: group_meta } } diff --git a/app/views/layouts/nav/projects_dropdown/_show.html.haml b/app/views/layouts/nav/projects_dropdown/_show.html.haml index 5809d6f7fea..f2170f71532 100644 --- a/app/views/layouts/nav/projects_dropdown/_show.html.haml +++ b/app/views/layouts/nav/projects_dropdown/_show.html.haml @@ -1,6 +1,6 @@ - project_meta = { id: @project.id, name: @project.name, namespace: @project.full_name, web_url: project_path(@project), avatar_url: @project.avatar_url } if @project&.persisted? -.projects-dropdown-container - .project-dropdown-sidebar.qa-projects-dropdown-sidebar +.frequent-items-dropdown-container + .frequent-items-dropdown-sidebar.qa-projects-dropdown-sidebar %ul = nav_link(path: 'dashboard/projects#index') do = link_to dashboard_projects_path, class: 'qa-your-projects-link' do @@ -11,5 +11,5 @@ = nav_link(path: 'projects#trending') do = link_to explore_root_path do = _('Explore projects') - .project-dropdown-content + .frequent-items-dropdown-content #js-projects-dropdown{ data: { user_name: current_user.username, project: project_meta } } |