diff options
author | Dennis Tang <dennis@dennistang.net> | 2018-07-06 13:40:11 +0000 |
---|---|---|
committer | Phil Hughes <me@iamphill.com> | 2018-07-06 13:40:11 +0000 |
commit | 3892b022e3173851f418e4bd8469f0dcdde2ebef (patch) | |
tree | 4379c1214ca409902e0d858551282e2dd0c262aa | |
parent | b14b31b819f0f09d73e001a80acd528aad913dc9 (diff) | |
download | gitlab-ce-3892b022e3173851f418e4bd8469f0dcdde2ebef.tar.gz |
Resolve "Add dropdown to Groups link in top bar"
49 files changed, 1894 insertions, 1706 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 } } diff --git a/changelogs/unreleased/36234-nav-add-groups-dropdown.yml b/changelogs/unreleased/36234-nav-add-groups-dropdown.yml new file mode 100644 index 00000000000..86a24102665 --- /dev/null +++ b/changelogs/unreleased/36234-nav-add-groups-dropdown.yml @@ -0,0 +1,5 @@ +--- +title: Add dropdown to Groups link in top bar +merge_request: 18280 +author: +type: added diff --git a/qa/qa/page/menu/main.rb b/qa/qa/page/menu/main.rb index fda9c45c091..aef5c9f9c82 100644 --- a/qa/qa/page/menu/main.rb +++ b/qa/qa/page/menu/main.rb @@ -16,7 +16,7 @@ module QA view 'app/views/layouts/nav/_dashboard.html.haml' do element :admin_area_link element :projects_dropdown - element :groups_link + element :groups_dropdown end view 'app/views/layouts/nav/projects_dropdown/_show.html.haml' do @@ -25,7 +25,13 @@ module QA end def go_to_groups - within_top_menu { click_element :groups_link } + within_top_menu do + click_element :groups_dropdown + end + + page.within('.qa-groups-dropdown-sidebar') do + click_element :your_groups_link + end end def go_to_projects diff --git a/spec/javascripts/frequent_items/components/app_spec.js b/spec/javascripts/frequent_items/components/app_spec.js new file mode 100644 index 00000000000..834f919524d --- /dev/null +++ b/spec/javascripts/frequent_items/components/app_spec.js @@ -0,0 +1,251 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import Vue from 'vue'; +import appComponent from '~/frequent_items/components/app.vue'; +import eventHub from '~/frequent_items/event_hub'; +import store from '~/frequent_items/store'; +import { FREQUENT_ITEMS, HOUR_IN_MS } from '~/frequent_items/constants'; +import { getTopFrequentItems } from '~/frequent_items/utils'; +import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { currentSession, mockFrequentProjects, mockSearchedProjects } from '../mock_data'; + +let session; +const createComponentWithStore = (namespace = 'projects') => { + session = currentSession[namespace]; + gon.api_version = session.apiVersion; + const Component = Vue.extend(appComponent); + + return mountComponentWithStore(Component, { + store, + props: { + namespace, + currentUserName: session.username, + currentItem: session.project || session.group, + }, + }); +}; + +describe('Frequent Items App Component', () => { + let vm; + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + vm = createComponentWithStore(); + }); + + afterEach(() => { + mock.restore(); + vm.$destroy(); + }); + + describe('methods', () => { + describe('dropdownOpenHandler', () => { + it('should fetch frequent items when no search has been previously made on desktop', () => { + spyOn(vm, 'fetchFrequentItems'); + + vm.dropdownOpenHandler(); + + expect(vm.fetchFrequentItems).toHaveBeenCalledWith(); + }); + }); + + describe('logItemAccess', () => { + let storage; + + beforeEach(() => { + storage = {}; + + spyOn(window.localStorage, 'setItem').and.callFake((storageKey, value) => { + storage[storageKey] = value; + }); + + spyOn(window.localStorage, 'getItem').and.callFake(storageKey => { + if (storage[storageKey]) { + return storage[storageKey]; + } + + return null; + }); + }); + + it('should create a project store if it does not exist and adds a project', () => { + vm.logItemAccess(session.storageKey, session.project); + + const projects = JSON.parse(storage[session.storageKey]); + + expect(projects.length).toBe(1); + expect(projects[0].frequency).toBe(1); + expect(projects[0].lastAccessedOn).toBeDefined(); + }); + + it('should prevent inserting same report multiple times into store', () => { + vm.logItemAccess(session.storageKey, session.project); + vm.logItemAccess(session.storageKey, session.project); + + const projects = JSON.parse(storage[session.storageKey]); + + expect(projects.length).toBe(1); + }); + + it('should increase frequency of report if it was logged multiple times over the course of an hour', () => { + let projects; + const newTimestamp = Date.now() + HOUR_IN_MS + 1; + + vm.logItemAccess(session.storageKey, session.project); + projects = JSON.parse(storage[session.storageKey]); + + expect(projects[0].frequency).toBe(1); + + vm.logItemAccess(session.storageKey, { + ...session.project, + lastAccessedOn: newTimestamp, + }); + projects = JSON.parse(storage[session.storageKey]); + + expect(projects[0].frequency).toBe(2); + expect(projects[0].lastAccessedOn).not.toBe(session.project.lastAccessedOn); + }); + + it('should always update project metadata', () => { + let projects; + const oldProject = { + ...session.project, + }; + + const newProject = { + ...session.project, + name: 'New Name', + avatarUrl: 'new/avatar.png', + namespace: 'New / Namespace', + webUrl: 'http://localhost/new/web/url', + }; + + vm.logItemAccess(session.storageKey, oldProject); + projects = JSON.parse(storage[session.storageKey]); + + expect(projects[0].name).toBe(oldProject.name); + expect(projects[0].avatarUrl).toBe(oldProject.avatarUrl); + expect(projects[0].namespace).toBe(oldProject.namespace); + expect(projects[0].webUrl).toBe(oldProject.webUrl); + + vm.logItemAccess(session.storageKey, newProject); + projects = JSON.parse(storage[session.storageKey]); + + expect(projects[0].name).toBe(newProject.name); + expect(projects[0].avatarUrl).toBe(newProject.avatarUrl); + expect(projects[0].namespace).toBe(newProject.namespace); + expect(projects[0].webUrl).toBe(newProject.webUrl); + }); + + it('should not add more than 20 projects in store', () => { + for (let id = 0; id < FREQUENT_ITEMS.MAX_COUNT; id += 1) { + const project = { + ...session.project, + id, + }; + vm.logItemAccess(session.storageKey, project); + } + + const projects = JSON.parse(storage[session.storageKey]); + + expect(projects.length).toBe(FREQUENT_ITEMS.MAX_COUNT); + }); + }); + }); + + describe('created', () => { + it('should bind event listeners on eventHub', done => { + spyOn(eventHub, '$on'); + + createComponentWithStore().$mount(); + + Vue.nextTick(() => { + expect(eventHub.$on).toHaveBeenCalledWith('projects-dropdownOpen', jasmine.any(Function)); + done(); + }); + }); + }); + + describe('beforeDestroy', () => { + it('should unbind event listeners on eventHub', done => { + spyOn(eventHub, '$off'); + + vm.$mount(); + vm.$destroy(); + + Vue.nextTick(() => { + expect(eventHub.$off).toHaveBeenCalledWith('projects-dropdownOpen', jasmine.any(Function)); + done(); + }); + }); + }); + + describe('template', () => { + it('should render search input', () => { + expect(vm.$el.querySelector('.search-input-container')).toBeDefined(); + }); + + it('should render loading animation', done => { + vm.$store.dispatch('fetchSearchedItems'); + + Vue.nextTick(() => { + const loadingEl = vm.$el.querySelector('.loading-animation'); + + expect(loadingEl).toBeDefined(); + expect(loadingEl.classList.contains('prepend-top-20')).toBe(true); + expect(loadingEl.querySelector('i').getAttribute('aria-label')).toBe('Loading projects'); + done(); + }); + }); + + it('should render frequent projects list header', done => { + Vue.nextTick(() => { + const sectionHeaderEl = vm.$el.querySelector('.section-header'); + + expect(sectionHeaderEl).toBeDefined(); + expect(sectionHeaderEl.innerText.trim()).toBe('Frequently visited'); + done(); + }); + }); + + it('should render frequent projects list', done => { + const expectedResult = getTopFrequentItems(mockFrequentProjects); + spyOn(window.localStorage, 'getItem').and.callFake(() => + JSON.stringify(mockFrequentProjects), + ); + + expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(1); + + vm.fetchFrequentItems(); + Vue.nextTick(() => { + expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe( + expectedResult.length, + ); + done(); + }); + }); + + it('should render searched projects list', done => { + mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects); + + expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(1); + + vm.$store.dispatch('setSearchQuery', 'gitlab'); + vm + .$nextTick() + .then(() => { + expect(vm.$el.querySelector('.loading-animation')).toBeDefined(); + }) + .then(vm.$nextTick) + .then(vm.$nextTick) + .then(() => { + expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe( + mockSearchedProjects.length, + ); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js b/spec/javascripts/frequent_items/components/frequent_items_list_item_spec.js index c193258474e..201aca77b10 100644 --- a/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js +++ b/spec/javascripts/frequent_items/components/frequent_items_list_item_spec.js @@ -1,23 +1,21 @@ import Vue from 'vue'; - -import projectsListItemComponent from '~/projects_dropdown/components/projects_list_item.vue'; - +import frequentItemsListItemComponent from '~/frequent_items/components/frequent_items_list_item.vue'; import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import { mockProject } from '../mock_data'; +import { mockProject } from '../mock_data'; // can also use 'mockGroup', but not useful to test here const createComponent = () => { - const Component = Vue.extend(projectsListItemComponent); + const Component = Vue.extend(frequentItemsListItemComponent); return mountComponent(Component, { - projectId: mockProject.id, - projectName: mockProject.name, + itemId: mockProject.id, + itemName: mockProject.name, namespace: mockProject.namespace, webUrl: mockProject.webUrl, avatarUrl: mockProject.avatarUrl, }); }; -describe('ProjectsListItemComponent', () => { +describe('FrequentItemsListItemComponent', () => { let vm; beforeEach(() => { @@ -32,22 +30,22 @@ describe('ProjectsListItemComponent', () => { describe('hasAvatar', () => { it('should return `true` or `false` if whether avatar is present or not', () => { vm.avatarUrl = 'path/to/avatar.png'; - expect(vm.hasAvatar).toBeTruthy(); + expect(vm.hasAvatar).toBe(true); vm.avatarUrl = null; - expect(vm.hasAvatar).toBeFalsy(); + expect(vm.hasAvatar).toBe(false); }); }); - describe('highlightedProjectName', () => { + describe('highlightedItemName', () => { it('should enclose part of project name in <b> & </b> which matches with `matcher` prop', () => { vm.matcher = 'lab'; - expect(vm.highlightedProjectName).toContain('<b>Lab</b>'); + expect(vm.highlightedItemName).toContain('<b>Lab</b>'); }); it('should return project name as it is if `matcher` is not available', () => { vm.matcher = null; - expect(vm.highlightedProjectName).toBe(mockProject.name); + expect(vm.highlightedItemName).toBe(mockProject.name); }); }); @@ -66,12 +64,12 @@ describe('ProjectsListItemComponent', () => { describe('template', () => { it('should render component element', () => { - expect(vm.$el.classList.contains('projects-list-item-container')).toBeTruthy(); + expect(vm.$el.classList.contains('frequent-items-list-item-container')).toBeTruthy(); expect(vm.$el.querySelectorAll('a').length).toBe(1); - expect(vm.$el.querySelectorAll('.project-item-avatar-container').length).toBe(1); - expect(vm.$el.querySelectorAll('.project-item-metadata-container').length).toBe(1); - expect(vm.$el.querySelectorAll('.project-title').length).toBe(1); - expect(vm.$el.querySelectorAll('.project-namespace').length).toBe(1); + expect(vm.$el.querySelectorAll('.frequent-items-item-avatar-container').length).toBe(1); + expect(vm.$el.querySelectorAll('.frequent-items-item-metadata-container').length).toBe(1); + expect(vm.$el.querySelectorAll('.frequent-items-item-title').length).toBe(1); + expect(vm.$el.querySelectorAll('.frequent-items-item-namespace').length).toBe(1); }); }); }); diff --git a/spec/javascripts/frequent_items/components/frequent_items_list_spec.js b/spec/javascripts/frequent_items/components/frequent_items_list_spec.js new file mode 100644 index 00000000000..3003b7ee000 --- /dev/null +++ b/spec/javascripts/frequent_items/components/frequent_items_list_spec.js @@ -0,0 +1,84 @@ +import Vue from 'vue'; +import frequentItemsListComponent from '~/frequent_items/components/frequent_items_list.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import { mockFrequentProjects } from '../mock_data'; + +const createComponent = (namespace = 'projects') => { + const Component = Vue.extend(frequentItemsListComponent); + + return mountComponent(Component, { + namespace, + items: mockFrequentProjects, + isFetchFailed: false, + hasSearchQuery: false, + matcher: 'lab', + }); +}; + +describe('FrequentItemsListComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('isListEmpty', () => { + it('should return `true` or `false` representing whether if `items` is empty or not with projects', () => { + vm.items = []; + expect(vm.isListEmpty).toBe(true); + + vm.items = mockFrequentProjects; + expect(vm.isListEmpty).toBe(false); + }); + }); + + describe('fetched item messages', () => { + it('should return appropriate empty list message based on value of `localStorageFailed` prop with projects', () => { + vm.isFetchFailed = true; + expect(vm.listEmptyMessage).toBe('This feature requires browser localStorage support'); + + vm.isFetchFailed = false; + expect(vm.listEmptyMessage).toBe('Projects you visit often will appear here'); + }); + }); + + describe('searched item messages', () => { + it('should return appropriate empty list message based on value of `searchFailed` prop with projects', () => { + vm.hasSearchQuery = true; + vm.isFetchFailed = true; + expect(vm.listEmptyMessage).toBe('Something went wrong on our end.'); + + vm.isFetchFailed = false; + expect(vm.listEmptyMessage).toBe('Sorry, no projects matched your search'); + }); + }); + }); + + describe('template', () => { + it('should render component element with list of projects', done => { + vm.items = mockFrequentProjects; + + Vue.nextTick(() => { + expect(vm.$el.classList.contains('frequent-items-list-container')).toBe(true); + expect(vm.$el.querySelectorAll('ul.list-unstyled').length).toBe(1); + expect(vm.$el.querySelectorAll('li.frequent-items-list-item-container').length).toBe(5); + done(); + }); + }); + + it('should render component element with empty message', done => { + vm.items = []; + + Vue.nextTick(() => { + expect(vm.$el.querySelectorAll('li.section-empty').length).toBe(1); + expect(vm.$el.querySelectorAll('li.frequent-items-list-item-container').length).toBe(0); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/frequent_items/components/frequent_items_search_input_spec.js b/spec/javascripts/frequent_items/components/frequent_items_search_input_spec.js new file mode 100644 index 00000000000..6a11038e70a --- /dev/null +++ b/spec/javascripts/frequent_items/components/frequent_items_search_input_spec.js @@ -0,0 +1,77 @@ +import Vue from 'vue'; +import searchComponent from '~/frequent_items/components/frequent_items_search_input.vue'; +import eventHub from '~/frequent_items/event_hub'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +const createComponent = (namespace = 'projects') => { + const Component = Vue.extend(searchComponent); + + return mountComponent(Component, { namespace }); +}; + +describe('FrequentItemsSearchInputComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('methods', () => { + describe('setFocus', () => { + it('should set focus to search input', () => { + spyOn(vm.$refs.search, 'focus'); + + vm.setFocus(); + expect(vm.$refs.search.focus).toHaveBeenCalled(); + }); + }); + }); + + describe('mounted', () => { + it('should listen `dropdownOpen` event', done => { + spyOn(eventHub, '$on'); + const vmX = createComponent(); + + Vue.nextTick(() => { + expect(eventHub.$on).toHaveBeenCalledWith( + `${vmX.namespace}-dropdownOpen`, + jasmine.any(Function), + ); + done(); + }); + }); + }); + + describe('beforeDestroy', () => { + it('should unbind event listeners on eventHub', done => { + const vmX = createComponent(); + spyOn(eventHub, '$off'); + + vmX.$mount(); + vmX.$destroy(); + + Vue.nextTick(() => { + expect(eventHub.$off).toHaveBeenCalledWith( + `${vmX.namespace}-dropdownOpen`, + jasmine.any(Function), + ); + done(); + }); + }); + }); + + describe('template', () => { + it('should render component element', () => { + const inputEl = vm.$el.querySelector('input.form-control'); + + expect(vm.$el.classList.contains('search-input-container')).toBeTruthy(); + expect(inputEl).not.toBe(null); + expect(inputEl.getAttribute('placeholder')).toBe('Search your projects'); + expect(vm.$el.querySelector('.search-icon')).toBeDefined(); + }); + }); +}); diff --git a/spec/javascripts/frequent_items/mock_data.js b/spec/javascripts/frequent_items/mock_data.js new file mode 100644 index 00000000000..cf3602f42d6 --- /dev/null +++ b/spec/javascripts/frequent_items/mock_data.js @@ -0,0 +1,168 @@ +export const currentSession = { + groups: { + username: 'root', + storageKey: 'root/frequent-groups', + apiVersion: 'v4', + group: { + id: 1, + name: 'dummy-group', + full_name: 'dummy-parent-group', + webUrl: `${gl.TEST_HOST}/dummy-group`, + avatarUrl: null, + lastAccessedOn: Date.now(), + }, + }, + projects: { + username: 'root', + storageKey: 'root/frequent-projects', + apiVersion: 'v4', + project: { + id: 1, + name: 'dummy-project', + namespace: 'SampleGroup / Dummy-Project', + webUrl: `${gl.TEST_HOST}/samplegroup/dummy-project`, + avatarUrl: null, + lastAccessedOn: Date.now(), + }, + }, +}; + +export const mockNamespace = 'projects'; + +export const mockStorageKey = 'test-user/frequent-projects'; + +export const mockGroup = { + id: 1, + name: 'Sub451', + namespace: 'Commit451 / Sub451', + webUrl: `${gl.TEST_HOST}/Commit451/Sub451`, + avatarUrl: null, +}; + +export const mockRawGroup = { + id: 1, + name: 'Sub451', + full_name: 'Commit451 / Sub451', + web_url: `${gl.TEST_HOST}/Commit451/Sub451`, + avatar_url: null, +}; + +export const mockFrequentGroups = [ + { + id: 3, + name: 'Subgroup451', + full_name: 'Commit451 / Subgroup451', + webUrl: '/Commit451/Subgroup451', + avatarUrl: null, + frequency: 7, + lastAccessedOn: 1497979281815, + }, + { + id: 1, + name: 'Commit451', + full_name: 'Commit451', + webUrl: '/Commit451', + avatarUrl: null, + frequency: 3, + lastAccessedOn: 1497979281815, + }, +]; + +export const mockSearchedGroups = [mockRawGroup]; +export const mockProcessedSearchedGroups = [mockGroup]; + +export const mockProject = { + id: 1, + name: 'GitLab Community Edition', + namespace: 'gitlab-org / gitlab-ce', + webUrl: `${gl.TEST_HOST}/gitlab-org/gitlab-ce`, + avatarUrl: null, +}; + +export const mockRawProject = { + id: 1, + name: 'GitLab Community Edition', + name_with_namespace: 'gitlab-org / gitlab-ce', + web_url: `${gl.TEST_HOST}/gitlab-org/gitlab-ce`, + avatar_url: null, +}; + +export const mockFrequentProjects = [ + { + id: 1, + name: 'GitLab Community Edition', + namespace: 'gitlab-org / gitlab-ce', + webUrl: `${gl.TEST_HOST}/gitlab-org/gitlab-ce`, + avatarUrl: null, + frequency: 1, + lastAccessedOn: Date.now(), + }, + { + id: 2, + name: 'GitLab CI', + namespace: 'gitlab-org / gitlab-ci', + webUrl: `${gl.TEST_HOST}/gitlab-org/gitlab-ci`, + avatarUrl: null, + frequency: 9, + lastAccessedOn: Date.now(), + }, + { + id: 3, + name: 'Typeahead.Js', + namespace: 'twitter / typeahead-js', + webUrl: `${gl.TEST_HOST}/twitter/typeahead-js`, + avatarUrl: '/uploads/-/system/project/avatar/7/TWBS.png', + frequency: 2, + lastAccessedOn: Date.now(), + }, + { + id: 4, + name: 'Intel', + namespace: 'platform / hardware / bsp / intel', + webUrl: `${gl.TEST_HOST}/platform/hardware/bsp/intel`, + avatarUrl: null, + frequency: 3, + lastAccessedOn: Date.now(), + }, + { + id: 5, + name: 'v4.4', + namespace: 'platform / hardware / bsp / kernel / common / v4.4', + webUrl: `${gl.TEST_HOST}/platform/hardware/bsp/kernel/common/v4.4`, + avatarUrl: null, + frequency: 8, + lastAccessedOn: Date.now(), + }, +]; + +export const mockSearchedProjects = [mockRawProject]; +export const mockProcessedSearchedProjects = [mockProject]; + +export const unsortedFrequentItems = [ + { id: 1, frequency: 12, lastAccessedOn: 1491400843391 }, + { id: 2, frequency: 14, lastAccessedOn: 1488240890738 }, + { id: 3, frequency: 44, lastAccessedOn: 1497675908472 }, + { id: 4, frequency: 8, lastAccessedOn: 1497979281815 }, + { id: 5, frequency: 34, lastAccessedOn: 1488089211943 }, + { id: 6, frequency: 14, lastAccessedOn: 1493517292488 }, + { id: 7, frequency: 42, lastAccessedOn: 1486815299875 }, + { id: 8, frequency: 33, lastAccessedOn: 1500762279114 }, + { id: 10, frequency: 46, lastAccessedOn: 1483251641543 }, +]; + +/** + * This const has a specific order which tests authenticity + * of `getTopFrequentItems` method so + * DO NOT change order of items in this const. + */ +export const sortedFrequentItems = [ + { id: 10, frequency: 46, lastAccessedOn: 1483251641543 }, + { id: 3, frequency: 44, lastAccessedOn: 1497675908472 }, + { id: 7, frequency: 42, lastAccessedOn: 1486815299875 }, + { id: 5, frequency: 34, lastAccessedOn: 1488089211943 }, + { id: 8, frequency: 33, lastAccessedOn: 1500762279114 }, + { id: 6, frequency: 14, lastAccessedOn: 1493517292488 }, + { id: 2, frequency: 14, lastAccessedOn: 1488240890738 }, + { id: 1, frequency: 12, lastAccessedOn: 1491400843391 }, + { id: 4, frequency: 8, lastAccessedOn: 1497979281815 }, +]; diff --git a/spec/javascripts/frequent_items/store/actions_spec.js b/spec/javascripts/frequent_items/store/actions_spec.js new file mode 100644 index 00000000000..0cdd033d38f --- /dev/null +++ b/spec/javascripts/frequent_items/store/actions_spec.js @@ -0,0 +1,225 @@ +import testAction from 'spec/helpers/vuex_action_helper'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import AccessorUtilities from '~/lib/utils/accessor'; +import * as actions from '~/frequent_items/store/actions'; +import * as types from '~/frequent_items/store/mutation_types'; +import state from '~/frequent_items/store/state'; +import { + mockNamespace, + mockStorageKey, + mockFrequentProjects, + mockSearchedProjects, +} from '../mock_data'; + +describe('Frequent Items Dropdown Store Actions', () => { + let mockedState; + let mock; + + beforeEach(() => { + mockedState = state(); + mock = new MockAdapter(axios); + + mockedState.namespace = mockNamespace; + mockedState.storageKey = mockStorageKey; + }); + + afterEach(() => { + mock.restore(); + }); + + describe('setNamespace', () => { + it('should set namespace', done => { + testAction( + actions.setNamespace, + mockNamespace, + mockedState, + [{ type: types.SET_NAMESPACE, payload: mockNamespace }], + [], + done, + ); + }); + }); + + describe('setStorageKey', () => { + it('should set storage key', done => { + testAction( + actions.setStorageKey, + mockStorageKey, + mockedState, + [{ type: types.SET_STORAGE_KEY, payload: mockStorageKey }], + [], + done, + ); + }); + }); + + describe('requestFrequentItems', () => { + it('should request frequent items', done => { + testAction( + actions.requestFrequentItems, + null, + mockedState, + [{ type: types.REQUEST_FREQUENT_ITEMS }], + [], + done, + ); + }); + }); + + describe('receiveFrequentItemsSuccess', () => { + it('should set frequent items', done => { + testAction( + actions.receiveFrequentItemsSuccess, + mockFrequentProjects, + mockedState, + [{ type: types.RECEIVE_FREQUENT_ITEMS_SUCCESS, payload: mockFrequentProjects }], + [], + done, + ); + }); + }); + + describe('receiveFrequentItemsError', () => { + it('should set frequent items error state', done => { + testAction( + actions.receiveFrequentItemsError, + null, + mockedState, + [{ type: types.RECEIVE_FREQUENT_ITEMS_ERROR }], + [], + done, + ); + }); + }); + + describe('fetchFrequentItems', () => { + it('should dispatch `receiveFrequentItemsSuccess`', done => { + mockedState.namespace = mockNamespace; + mockedState.storageKey = mockStorageKey; + + testAction( + actions.fetchFrequentItems, + null, + mockedState, + [], + [{ type: 'requestFrequentItems' }, { type: 'receiveFrequentItemsSuccess', payload: [] }], + done, + ); + }); + + it('should dispatch `receiveFrequentItemsError`', done => { + spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.returnValue(false); + mockedState.namespace = mockNamespace; + mockedState.storageKey = mockStorageKey; + + testAction( + actions.fetchFrequentItems, + null, + mockedState, + [], + [{ type: 'requestFrequentItems' }, { type: 'receiveFrequentItemsError' }], + done, + ); + }); + }); + + describe('requestSearchedItems', () => { + it('should request searched items', done => { + testAction( + actions.requestSearchedItems, + null, + mockedState, + [{ type: types.REQUEST_SEARCHED_ITEMS }], + [], + done, + ); + }); + }); + + describe('receiveSearchedItemsSuccess', () => { + it('should set searched items', done => { + testAction( + actions.receiveSearchedItemsSuccess, + mockSearchedProjects, + mockedState, + [{ type: types.RECEIVE_SEARCHED_ITEMS_SUCCESS, payload: mockSearchedProjects }], + [], + done, + ); + }); + }); + + describe('receiveSearchedItemsError', () => { + it('should set searched items error state', done => { + testAction( + actions.receiveSearchedItemsError, + null, + mockedState, + [{ type: types.RECEIVE_SEARCHED_ITEMS_ERROR }], + [], + done, + ); + }); + }); + + describe('fetchSearchedItems', () => { + beforeEach(() => { + gon.api_version = 'v4'; + }); + + it('should dispatch `receiveSearchedItemsSuccess`', done => { + mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects); + + testAction( + actions.fetchSearchedItems, + null, + mockedState, + [], + [ + { type: 'requestSearchedItems' }, + { type: 'receiveSearchedItemsSuccess', payload: mockSearchedProjects }, + ], + done, + ); + }); + + it('should dispatch `receiveSearchedItemsError`', done => { + gon.api_version = 'v4'; + mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(500); + + testAction( + actions.fetchSearchedItems, + null, + mockedState, + [], + [{ type: 'requestSearchedItems' }, { type: 'receiveSearchedItemsError' }], + done, + ); + }); + }); + + describe('setSearchQuery', () => { + it('should commit query and dispatch `fetchSearchedItems` when query is present', done => { + testAction( + actions.setSearchQuery, + { query: 'test' }, + mockedState, + [{ type: types.SET_SEARCH_QUERY }], + [{ type: 'fetchSearchedItems', payload: { query: 'test' } }], + done, + ); + }); + + it('should commit query and dispatch `fetchFrequentItems` when query is empty', done => { + testAction( + actions.setSearchQuery, + null, + mockedState, + [{ type: types.SET_SEARCH_QUERY }], + [{ type: 'fetchFrequentItems' }], + done, + ); + }); + }); +}); diff --git a/spec/javascripts/frequent_items/store/getters_spec.js b/spec/javascripts/frequent_items/store/getters_spec.js new file mode 100644 index 00000000000..1cd12eb6832 --- /dev/null +++ b/spec/javascripts/frequent_items/store/getters_spec.js @@ -0,0 +1,24 @@ +import state from '~/frequent_items/store/state'; +import * as getters from '~/frequent_items/store/getters'; + +describe('Frequent Items Dropdown Store Getters', () => { + let mockedState; + + beforeEach(() => { + mockedState = state(); + }); + + describe('hasSearchQuery', () => { + it('should return `true` when search query is present', () => { + mockedState.searchQuery = 'test'; + + expect(getters.hasSearchQuery(mockedState)).toBe(true); + }); + + it('should return `false` when search query is empty', () => { + mockedState.searchQuery = ''; + + expect(getters.hasSearchQuery(mockedState)).toBe(false); + }); + }); +}); diff --git a/spec/javascripts/frequent_items/store/mutations_spec.js b/spec/javascripts/frequent_items/store/mutations_spec.js new file mode 100644 index 00000000000..d36964b2600 --- /dev/null +++ b/spec/javascripts/frequent_items/store/mutations_spec.js @@ -0,0 +1,117 @@ +import state from '~/frequent_items/store/state'; +import mutations from '~/frequent_items/store/mutations'; +import * as types from '~/frequent_items/store/mutation_types'; +import { + mockNamespace, + mockStorageKey, + mockFrequentProjects, + mockSearchedProjects, + mockProcessedSearchedProjects, + mockSearchedGroups, + mockProcessedSearchedGroups, +} from '../mock_data'; + +describe('Frequent Items dropdown mutations', () => { + let stateCopy; + + beforeEach(() => { + stateCopy = state(); + }); + + describe('SET_NAMESPACE', () => { + it('should set namespace', () => { + mutations[types.SET_NAMESPACE](stateCopy, mockNamespace); + + expect(stateCopy.namespace).toEqual(mockNamespace); + }); + }); + + describe('SET_STORAGE_KEY', () => { + it('should set storage key', () => { + mutations[types.SET_STORAGE_KEY](stateCopy, mockStorageKey); + + expect(stateCopy.storageKey).toEqual(mockStorageKey); + }); + }); + + describe('SET_SEARCH_QUERY', () => { + it('should set search query', () => { + const searchQuery = 'gitlab-ce'; + + mutations[types.SET_SEARCH_QUERY](stateCopy, searchQuery); + + expect(stateCopy.searchQuery).toEqual(searchQuery); + }); + }); + + describe('REQUEST_FREQUENT_ITEMS', () => { + it('should set view states when requesting frequent items', () => { + mutations[types.REQUEST_FREQUENT_ITEMS](stateCopy); + + expect(stateCopy.isLoadingItems).toEqual(true); + expect(stateCopy.hasSearchQuery).toEqual(false); + }); + }); + + describe('RECEIVE_FREQUENT_ITEMS_SUCCESS', () => { + it('should set view states when receiving frequent items', () => { + mutations[types.RECEIVE_FREQUENT_ITEMS_SUCCESS](stateCopy, mockFrequentProjects); + + expect(stateCopy.items).toEqual(mockFrequentProjects); + expect(stateCopy.isLoadingItems).toEqual(false); + expect(stateCopy.hasSearchQuery).toEqual(false); + expect(stateCopy.isFetchFailed).toEqual(false); + }); + }); + + describe('RECEIVE_FREQUENT_ITEMS_ERROR', () => { + it('should set items and view states when error occurs retrieving frequent items', () => { + mutations[types.RECEIVE_FREQUENT_ITEMS_ERROR](stateCopy); + + expect(stateCopy.items).toEqual([]); + expect(stateCopy.isLoadingItems).toEqual(false); + expect(stateCopy.hasSearchQuery).toEqual(false); + expect(stateCopy.isFetchFailed).toEqual(true); + }); + }); + + describe('REQUEST_SEARCHED_ITEMS', () => { + it('should set view states when requesting searched items', () => { + mutations[types.REQUEST_SEARCHED_ITEMS](stateCopy); + + expect(stateCopy.isLoadingItems).toEqual(true); + expect(stateCopy.hasSearchQuery).toEqual(true); + }); + }); + + describe('RECEIVE_SEARCHED_ITEMS_SUCCESS', () => { + it('should set items and view states when receiving searched items', () => { + mutations[types.RECEIVE_SEARCHED_ITEMS_SUCCESS](stateCopy, mockSearchedProjects); + + expect(stateCopy.items).toEqual(mockProcessedSearchedProjects); + expect(stateCopy.isLoadingItems).toEqual(false); + expect(stateCopy.hasSearchQuery).toEqual(true); + expect(stateCopy.isFetchFailed).toEqual(false); + }); + + it('should also handle the different `full_name` key for namespace in groups payload', () => { + mutations[types.RECEIVE_SEARCHED_ITEMS_SUCCESS](stateCopy, mockSearchedGroups); + + expect(stateCopy.items).toEqual(mockProcessedSearchedGroups); + expect(stateCopy.isLoadingItems).toEqual(false); + expect(stateCopy.hasSearchQuery).toEqual(true); + expect(stateCopy.isFetchFailed).toEqual(false); + }); + }); + + describe('RECEIVE_SEARCHED_ITEMS_ERROR', () => { + it('should set view states when error occurs retrieving searched items', () => { + mutations[types.RECEIVE_SEARCHED_ITEMS_ERROR](stateCopy); + + expect(stateCopy.items).toEqual([]); + expect(stateCopy.isLoadingItems).toEqual(false); + expect(stateCopy.hasSearchQuery).toEqual(true); + expect(stateCopy.isFetchFailed).toEqual(true); + }); + }); +}); diff --git a/spec/javascripts/frequent_items/utils_spec.js b/spec/javascripts/frequent_items/utils_spec.js new file mode 100644 index 00000000000..cd27d79b29a --- /dev/null +++ b/spec/javascripts/frequent_items/utils_spec.js @@ -0,0 +1,89 @@ +import bp from '~/breakpoints'; +import { isMobile, getTopFrequentItems, updateExistingFrequentItem } from '~/frequent_items/utils'; +import { HOUR_IN_MS, FREQUENT_ITEMS } from '~/frequent_items/constants'; +import { mockProject, unsortedFrequentItems, sortedFrequentItems } from './mock_data'; + +describe('Frequent Items utils spec', () => { + describe('isMobile', () => { + it('returns true when the screen is small ', () => { + spyOn(bp, 'getBreakpointSize').and.returnValue('sm'); + + expect(isMobile()).toBe(true); + }); + + it('returns true when the screen is extra-small ', () => { + spyOn(bp, 'getBreakpointSize').and.returnValue('xs'); + + expect(isMobile()).toBe(true); + }); + + it('returns false when the screen is larger than small ', () => { + spyOn(bp, 'getBreakpointSize').and.returnValue('md'); + + expect(isMobile()).toBe(false); + }); + }); + + describe('getTopFrequentItems', () => { + it('returns empty array if no items provided', () => { + const result = getTopFrequentItems(); + + expect(result.length).toBe(0); + }); + + it('returns correct amount of items for mobile', () => { + spyOn(bp, 'getBreakpointSize').and.returnValue('sm'); + const result = getTopFrequentItems(unsortedFrequentItems); + + expect(result.length).toBe(FREQUENT_ITEMS.LIST_COUNT_MOBILE); + }); + + it('returns correct amount of items for desktop', () => { + spyOn(bp, 'getBreakpointSize').and.returnValue('lg'); + const result = getTopFrequentItems(unsortedFrequentItems); + + expect(result.length).toBe(FREQUENT_ITEMS.LIST_COUNT_DESKTOP); + }); + + it('sorts frequent items in order of frequency and lastAccessedOn', () => { + spyOn(bp, 'getBreakpointSize').and.returnValue('lg'); + const result = getTopFrequentItems(unsortedFrequentItems); + const expectedResult = sortedFrequentItems.slice(0, FREQUENT_ITEMS.LIST_COUNT_DESKTOP); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('updateExistingFrequentItem', () => { + let mockedProject; + + beforeEach(() => { + mockedProject = { + ...mockProject, + frequency: 1, + lastAccessedOn: 1497979281815, + }; + }); + + it('updates item if accessed over an hour ago', () => { + const newTimestamp = Date.now() + HOUR_IN_MS + 1; + const newItem = { + ...mockedProject, + lastAccessedOn: newTimestamp, + }; + const result = updateExistingFrequentItem(mockedProject, newItem); + + expect(result.frequency).toBe(mockedProject.frequency + 1); + }); + + it('does not update item if accessed within the hour', () => { + const newItem = { + ...mockedProject, + lastAccessedOn: mockedProject.lastAccessedOn + HOUR_IN_MS, + }; + const result = updateExistingFrequentItem(mockedProject, newItem); + + expect(result.frequency).toBe(mockedProject.frequency); + }); + }); +}); diff --git a/spec/javascripts/projects_dropdown/components/app_spec.js b/spec/javascripts/projects_dropdown/components/app_spec.js deleted file mode 100644 index 38b31c3d727..00000000000 --- a/spec/javascripts/projects_dropdown/components/app_spec.js +++ /dev/null @@ -1,349 +0,0 @@ -import Vue from 'vue'; - -import bp from '~/breakpoints'; -import appComponent from '~/projects_dropdown/components/app.vue'; -import eventHub from '~/projects_dropdown/event_hub'; -import ProjectsStore from '~/projects_dropdown/store/projects_store'; -import ProjectsService from '~/projects_dropdown/service/projects_service'; - -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import { currentSession, mockProject, mockRawProject } from '../mock_data'; - -const createComponent = () => { - gon.api_version = currentSession.apiVersion; - const Component = Vue.extend(appComponent); - const store = new ProjectsStore(); - const service = new ProjectsService(currentSession.username); - - return mountComponent(Component, { - store, - service, - currentUserName: currentSession.username, - currentProject: currentSession.project, - }); -}; - -const returnServicePromise = (data, failed) => - new Promise((resolve, reject) => { - if (failed) { - reject(data); - } else { - resolve({ - json() { - return data; - }, - }); - } - }); - -describe('AppComponent', () => { - describe('computed', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('frequentProjects', () => { - it('should return list of frequently accessed projects from store', () => { - expect(vm.frequentProjects).toBeDefined(); - expect(vm.frequentProjects.length).toBe(0); - - vm.store.setFrequentProjects([mockProject]); - expect(vm.frequentProjects).toBeDefined(); - expect(vm.frequentProjects.length).toBe(1); - }); - }); - - describe('searchProjects', () => { - it('should return list of frequently accessed projects from store', () => { - expect(vm.searchProjects).toBeDefined(); - expect(vm.searchProjects.length).toBe(0); - - vm.store.setSearchedProjects([mockRawProject]); - expect(vm.searchProjects).toBeDefined(); - expect(vm.searchProjects.length).toBe(1); - }); - }); - }); - - describe('methods', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('toggleFrequentProjectsList', () => { - it('should toggle props which control visibility of Frequent Projects list from state passed', () => { - vm.toggleFrequentProjectsList(true); - expect(vm.isLoadingProjects).toBeFalsy(); - expect(vm.isSearchListVisible).toBeFalsy(); - expect(vm.isFrequentsListVisible).toBeTruthy(); - - vm.toggleFrequentProjectsList(false); - expect(vm.isLoadingProjects).toBeTruthy(); - expect(vm.isSearchListVisible).toBeTruthy(); - expect(vm.isFrequentsListVisible).toBeFalsy(); - }); - }); - - describe('toggleSearchProjectsList', () => { - it('should toggle props which control visibility of Searched Projects list from state passed', () => { - vm.toggleSearchProjectsList(true); - expect(vm.isLoadingProjects).toBeFalsy(); - expect(vm.isFrequentsListVisible).toBeFalsy(); - expect(vm.isSearchListVisible).toBeTruthy(); - - vm.toggleSearchProjectsList(false); - expect(vm.isLoadingProjects).toBeTruthy(); - expect(vm.isFrequentsListVisible).toBeTruthy(); - expect(vm.isSearchListVisible).toBeFalsy(); - }); - }); - - describe('toggleLoader', () => { - it('should toggle props which control visibility of list loading animation from state passed', () => { - vm.toggleLoader(true); - expect(vm.isFrequentsListVisible).toBeFalsy(); - expect(vm.isSearchListVisible).toBeFalsy(); - expect(vm.isLoadingProjects).toBeTruthy(); - - vm.toggleLoader(false); - expect(vm.isFrequentsListVisible).toBeTruthy(); - expect(vm.isSearchListVisible).toBeTruthy(); - expect(vm.isLoadingProjects).toBeFalsy(); - }); - }); - - describe('fetchFrequentProjects', () => { - it('should set props for loading animation to `true` while frequent projects list is being loaded', () => { - spyOn(vm, 'toggleLoader'); - - vm.fetchFrequentProjects(); - expect(vm.isLocalStorageFailed).toBeFalsy(); - expect(vm.toggleLoader).toHaveBeenCalledWith(true); - }); - - it('should set props for loading animation to `false` and props for frequent projects list to `true` once data is loaded', () => { - const mockData = [mockProject]; - - spyOn(vm.service, 'getFrequentProjects').and.returnValue(mockData); - spyOn(vm.store, 'setFrequentProjects'); - spyOn(vm, 'toggleFrequentProjectsList'); - - vm.fetchFrequentProjects(); - expect(vm.service.getFrequentProjects).toHaveBeenCalled(); - expect(vm.store.setFrequentProjects).toHaveBeenCalledWith(mockData); - expect(vm.toggleFrequentProjectsList).toHaveBeenCalledWith(true); - }); - - it('should set props for failure message to `true` when method fails to fetch frequent projects list', () => { - spyOn(vm.service, 'getFrequentProjects').and.returnValue(null); - spyOn(vm.store, 'setFrequentProjects'); - spyOn(vm, 'toggleFrequentProjectsList'); - - expect(vm.isLocalStorageFailed).toBeFalsy(); - - vm.fetchFrequentProjects(); - expect(vm.service.getFrequentProjects).toHaveBeenCalled(); - expect(vm.store.setFrequentProjects).toHaveBeenCalledWith([]); - expect(vm.toggleFrequentProjectsList).toHaveBeenCalledWith(true); - expect(vm.isLocalStorageFailed).toBeTruthy(); - }); - - it('should set props for search results list to `true` if search query was already made previously', () => { - spyOn(bp, 'getBreakpointSize').and.returnValue('md'); - spyOn(vm.service, 'getFrequentProjects'); - spyOn(vm, 'toggleSearchProjectsList'); - - vm.searchQuery = 'test'; - vm.fetchFrequentProjects(); - expect(vm.service.getFrequentProjects).not.toHaveBeenCalled(); - expect(vm.toggleSearchProjectsList).toHaveBeenCalledWith(true); - }); - - it('should set props for frequent projects list to `true` if search query was already made but screen size is less than 768px', () => { - spyOn(bp, 'getBreakpointSize').and.returnValue('sm'); - spyOn(vm, 'toggleSearchProjectsList'); - spyOn(vm.service, 'getFrequentProjects'); - - vm.searchQuery = 'test'; - vm.fetchFrequentProjects(); - expect(vm.service.getFrequentProjects).toHaveBeenCalled(); - expect(vm.toggleSearchProjectsList).not.toHaveBeenCalled(); - }); - }); - - describe('fetchSearchedProjects', () => { - const searchQuery = 'test'; - - it('should perform search with provided search query', done => { - const mockData = [mockRawProject]; - spyOn(vm, 'toggleLoader'); - spyOn(vm, 'toggleSearchProjectsList'); - spyOn(vm.service, 'getSearchedProjects').and.returnValue(returnServicePromise(mockData)); - spyOn(vm.store, 'setSearchedProjects'); - - vm.fetchSearchedProjects(searchQuery); - setTimeout(() => { - expect(vm.searchQuery).toBe(searchQuery); - expect(vm.toggleLoader).toHaveBeenCalledWith(true); - expect(vm.service.getSearchedProjects).toHaveBeenCalledWith(searchQuery); - expect(vm.toggleSearchProjectsList).toHaveBeenCalledWith(true); - expect(vm.store.setSearchedProjects).toHaveBeenCalledWith(mockData); - done(); - }, 0); - }); - - it('should update props for showing search failure', done => { - spyOn(vm, 'toggleSearchProjectsList'); - spyOn(vm.service, 'getSearchedProjects').and.returnValue(returnServicePromise({}, true)); - - vm.fetchSearchedProjects(searchQuery); - setTimeout(() => { - expect(vm.searchQuery).toBe(searchQuery); - expect(vm.service.getSearchedProjects).toHaveBeenCalledWith(searchQuery); - expect(vm.isSearchFailed).toBeTruthy(); - expect(vm.toggleSearchProjectsList).toHaveBeenCalledWith(true); - done(); - }, 0); - }); - }); - - describe('logCurrentProjectAccess', () => { - it('should log current project access via service', done => { - spyOn(vm.service, 'logProjectAccess'); - - vm.currentProject = mockProject; - vm.logCurrentProjectAccess(); - - setTimeout(() => { - expect(vm.service.logProjectAccess).toHaveBeenCalledWith(mockProject); - done(); - }, 1); - }); - }); - - describe('handleSearchClear', () => { - it('should show frequent projects list when search input is cleared', () => { - spyOn(vm.store, 'clearSearchedProjects'); - spyOn(vm, 'toggleFrequentProjectsList'); - - vm.handleSearchClear(); - - expect(vm.toggleFrequentProjectsList).toHaveBeenCalledWith(true); - expect(vm.store.clearSearchedProjects).toHaveBeenCalled(); - expect(vm.searchQuery).toBe(''); - }); - }); - - describe('handleSearchFailure', () => { - it('should show failure message within dropdown', () => { - spyOn(vm, 'toggleSearchProjectsList'); - - vm.handleSearchFailure(); - expect(vm.toggleSearchProjectsList).toHaveBeenCalledWith(true); - expect(vm.isSearchFailed).toBeTruthy(); - }); - }); - }); - - describe('created', () => { - it('should bind event listeners on eventHub', done => { - spyOn(eventHub, '$on'); - - createComponent().$mount(); - - Vue.nextTick(() => { - expect(eventHub.$on).toHaveBeenCalledWith('dropdownOpen', jasmine.any(Function)); - expect(eventHub.$on).toHaveBeenCalledWith('searchProjects', jasmine.any(Function)); - expect(eventHub.$on).toHaveBeenCalledWith('searchCleared', jasmine.any(Function)); - expect(eventHub.$on).toHaveBeenCalledWith('searchFailed', jasmine.any(Function)); - done(); - }); - }); - }); - - describe('beforeDestroy', () => { - it('should unbind event listeners on eventHub', done => { - const vm = createComponent(); - spyOn(eventHub, '$off'); - - vm.$mount(); - vm.$destroy(); - - Vue.nextTick(() => { - expect(eventHub.$off).toHaveBeenCalledWith('dropdownOpen', jasmine.any(Function)); - expect(eventHub.$off).toHaveBeenCalledWith('searchProjects', jasmine.any(Function)); - expect(eventHub.$off).toHaveBeenCalledWith('searchCleared', jasmine.any(Function)); - expect(eventHub.$off).toHaveBeenCalledWith('searchFailed', jasmine.any(Function)); - done(); - }); - }); - }); - - describe('template', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - it('should render search input', () => { - expect(vm.$el.querySelector('.search-input-container')).toBeDefined(); - }); - - it('should render loading animation', done => { - vm.toggleLoader(true); - Vue.nextTick(() => { - const loadingEl = vm.$el.querySelector('.loading-animation'); - - expect(loadingEl).toBeDefined(); - expect(loadingEl.classList.contains('prepend-top-20')).toBeTruthy(); - expect(loadingEl.querySelector('i').getAttribute('aria-label')).toBe('Loading projects'); - done(); - }); - }); - - it('should render frequent projects list header', done => { - vm.toggleFrequentProjectsList(true); - Vue.nextTick(() => { - const sectionHeaderEl = vm.$el.querySelector('.section-header'); - - expect(sectionHeaderEl).toBeDefined(); - expect(sectionHeaderEl.innerText.trim()).toBe('Frequently visited'); - done(); - }); - }); - - it('should render frequent projects list', done => { - vm.toggleFrequentProjectsList(true); - Vue.nextTick(() => { - expect(vm.$el.querySelector('.projects-list-frequent-container')).toBeDefined(); - done(); - }); - }); - - it('should render searched projects list', done => { - vm.toggleSearchProjectsList(true); - Vue.nextTick(() => { - expect(vm.$el.querySelector('.section-header')).toBe(null); - expect(vm.$el.querySelector('.projects-list-search-container')).toBeDefined(); - done(); - }); - }); - }); -}); diff --git a/spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js b/spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js deleted file mode 100644 index 2bafb4e81ca..00000000000 --- a/spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js +++ /dev/null @@ -1,72 +0,0 @@ -import Vue from 'vue'; - -import projectsListFrequentComponent from '~/projects_dropdown/components/projects_list_frequent.vue'; - -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import { mockFrequents } from '../mock_data'; - -const createComponent = () => { - const Component = Vue.extend(projectsListFrequentComponent); - - return mountComponent(Component, { - projects: mockFrequents, - localStorageFailed: false, - }); -}; - -describe('ProjectsListFrequentComponent', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('computed', () => { - describe('isListEmpty', () => { - it('should return `true` or `false` representing whether if `projects` is empty of not', () => { - vm.projects = []; - expect(vm.isListEmpty).toBeTruthy(); - - vm.projects = mockFrequents; - expect(vm.isListEmpty).toBeFalsy(); - }); - }); - - describe('listEmptyMessage', () => { - it('should return appropriate empty list message based on value of `localStorageFailed` prop', () => { - vm.localStorageFailed = true; - expect(vm.listEmptyMessage).toBe('This feature requires browser localStorage support'); - - vm.localStorageFailed = false; - expect(vm.listEmptyMessage).toBe('Projects you visit often will appear here'); - }); - }); - }); - - describe('template', () => { - it('should render component element with list of projects', (done) => { - vm.projects = mockFrequents; - - Vue.nextTick(() => { - expect(vm.$el.classList.contains('projects-list-frequent-container')).toBeTruthy(); - expect(vm.$el.querySelectorAll('ul.list-unstyled').length).toBe(1); - expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(5); - done(); - }); - }); - - it('should render component element with empty message', (done) => { - vm.projects = []; - - Vue.nextTick(() => { - expect(vm.$el.querySelectorAll('li.section-empty').length).toBe(1); - expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(0); - done(); - }); - }); - }); -}); diff --git a/spec/javascripts/projects_dropdown/components/projects_list_search_spec.js b/spec/javascripts/projects_dropdown/components/projects_list_search_spec.js deleted file mode 100644 index c4b86d77034..00000000000 --- a/spec/javascripts/projects_dropdown/components/projects_list_search_spec.js +++ /dev/null @@ -1,84 +0,0 @@ -import Vue from 'vue'; - -import projectsListSearchComponent from '~/projects_dropdown/components/projects_list_search.vue'; - -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import { mockProject } from '../mock_data'; - -const createComponent = () => { - const Component = Vue.extend(projectsListSearchComponent); - - return mountComponent(Component, { - projects: [mockProject], - matcher: 'lab', - searchFailed: false, - }); -}; - -describe('ProjectsListSearchComponent', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('computed', () => { - describe('isListEmpty', () => { - it('should return `true` or `false` representing whether if `projects` is empty of not', () => { - vm.projects = []; - expect(vm.isListEmpty).toBeTruthy(); - - vm.projects = [mockProject]; - expect(vm.isListEmpty).toBeFalsy(); - }); - }); - - describe('listEmptyMessage', () => { - it('should return appropriate empty list message based on value of `searchFailed` prop', () => { - vm.searchFailed = true; - expect(vm.listEmptyMessage).toBe('Something went wrong on our end.'); - - vm.searchFailed = false; - expect(vm.listEmptyMessage).toBe('Sorry, no projects matched your search'); - }); - }); - }); - - describe('template', () => { - it('should render component element with list of projects', (done) => { - vm.projects = [mockProject]; - - Vue.nextTick(() => { - expect(vm.$el.classList.contains('projects-list-search-container')).toBeTruthy(); - expect(vm.$el.querySelectorAll('ul.list-unstyled').length).toBe(1); - expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(1); - done(); - }); - }); - - it('should render component element with empty message', (done) => { - vm.projects = []; - - Vue.nextTick(() => { - expect(vm.$el.querySelectorAll('li.section-empty').length).toBe(1); - expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(0); - done(); - }); - }); - - it('should render component element with failure message', (done) => { - vm.searchFailed = true; - vm.projects = []; - - Vue.nextTick(() => { - expect(vm.$el.querySelectorAll('li.section-empty.section-failure').length).toBe(1); - expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(0); - done(); - }); - }); - }); -}); diff --git a/spec/javascripts/projects_dropdown/components/search_spec.js b/spec/javascripts/projects_dropdown/components/search_spec.js deleted file mode 100644 index 427f5024e3a..00000000000 --- a/spec/javascripts/projects_dropdown/components/search_spec.js +++ /dev/null @@ -1,100 +0,0 @@ -import Vue from 'vue'; - -import searchComponent from '~/projects_dropdown/components/search.vue'; -import eventHub from '~/projects_dropdown/event_hub'; - -import mountComponent from 'spec/helpers/vue_mount_component_helper'; - -const createComponent = () => { - const Component = Vue.extend(searchComponent); - - return mountComponent(Component); -}; - -describe('SearchComponent', () => { - describe('methods', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('setFocus', () => { - it('should set focus to search input', () => { - spyOn(vm.$refs.search, 'focus'); - - vm.setFocus(); - expect(vm.$refs.search.focus).toHaveBeenCalled(); - }); - }); - - describe('emitSearchEvents', () => { - it('should emit `searchProjects` event via eventHub when `searchQuery` present', () => { - const searchQuery = 'test'; - spyOn(eventHub, '$emit'); - vm.searchQuery = searchQuery; - vm.emitSearchEvents(); - expect(eventHub.$emit).toHaveBeenCalledWith('searchProjects', searchQuery); - }); - - it('should emit `searchCleared` event via eventHub when `searchQuery` is cleared', () => { - spyOn(eventHub, '$emit'); - vm.searchQuery = ''; - vm.emitSearchEvents(); - expect(eventHub.$emit).toHaveBeenCalledWith('searchCleared'); - }); - }); - }); - - describe('mounted', () => { - it('should listen `dropdownOpen` event', (done) => { - spyOn(eventHub, '$on'); - createComponent(); - - Vue.nextTick(() => { - expect(eventHub.$on).toHaveBeenCalledWith('dropdownOpen', jasmine.any(Function)); - done(); - }); - }); - }); - - describe('beforeDestroy', () => { - it('should unbind event listeners on eventHub', (done) => { - const vm = createComponent(); - spyOn(eventHub, '$off'); - - vm.$mount(); - vm.$destroy(); - - Vue.nextTick(() => { - expect(eventHub.$off).toHaveBeenCalledWith('dropdownOpen', jasmine.any(Function)); - done(); - }); - }); - }); - - describe('template', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - it('should render component element', () => { - const inputEl = vm.$el.querySelector('input.form-control'); - - expect(vm.$el.classList.contains('search-input-container')).toBeTruthy(); - expect(inputEl).not.toBe(null); - expect(inputEl.getAttribute('placeholder')).toBe('Search your projects'); - expect(vm.$el.querySelector('.search-icon')).toBeDefined(); - }); - }); -}); diff --git a/spec/javascripts/projects_dropdown/mock_data.js b/spec/javascripts/projects_dropdown/mock_data.js deleted file mode 100644 index d6a79fb8ac1..00000000000 --- a/spec/javascripts/projects_dropdown/mock_data.js +++ /dev/null @@ -1,96 +0,0 @@ -export const currentSession = { - username: 'root', - storageKey: 'root/frequent-projects', - apiVersion: 'v4', - project: { - id: 1, - name: 'dummy-project', - namespace: 'SamepleGroup / Dummy-Project', - webUrl: 'http://127.0.0.1/samplegroup/dummy-project', - avatarUrl: null, - lastAccessedOn: Date.now(), - }, -}; - -export const mockProject = { - id: 1, - name: 'GitLab Community Edition', - namespace: 'gitlab-org / gitlab-ce', - webUrl: 'http://127.0.0.1:3000/gitlab-org/gitlab-ce', - avatarUrl: null, -}; - -export const mockRawProject = { - id: 1, - name: 'GitLab Community Edition', - name_with_namespace: 'gitlab-org / gitlab-ce', - web_url: 'http://127.0.0.1:3000/gitlab-org/gitlab-ce', - avatar_url: null, -}; - -export const mockFrequents = [ - { - id: 1, - name: 'GitLab Community Edition', - namespace: 'gitlab-org / gitlab-ce', - webUrl: 'http://127.0.0.1:3000/gitlab-org/gitlab-ce', - avatarUrl: null, - }, - { - id: 2, - name: 'GitLab CI', - namespace: 'gitlab-org / gitlab-ci', - webUrl: 'http://127.0.0.1:3000/gitlab-org/gitlab-ci', - avatarUrl: null, - }, - { - id: 3, - name: 'Typeahead.Js', - namespace: 'twitter / typeahead-js', - webUrl: 'http://127.0.0.1:3000/twitter/typeahead-js', - avatarUrl: '/uploads/-/system/project/avatar/7/TWBS.png', - }, - { - id: 4, - name: 'Intel', - namespace: 'platform / hardware / bsp / intel', - webUrl: 'http://127.0.0.1:3000/platform/hardware/bsp/intel', - avatarUrl: null, - }, - { - id: 5, - name: 'v4.4', - namespace: 'platform / hardware / bsp / kernel / common / v4.4', - webUrl: 'http://localhost:3000/platform/hardware/bsp/kernel/common/v4.4', - avatarUrl: null, - }, -]; - -export const unsortedFrequents = [ - { id: 1, frequency: 12, lastAccessedOn: 1491400843391 }, - { id: 2, frequency: 14, lastAccessedOn: 1488240890738 }, - { id: 3, frequency: 44, lastAccessedOn: 1497675908472 }, - { id: 4, frequency: 8, lastAccessedOn: 1497979281815 }, - { id: 5, frequency: 34, lastAccessedOn: 1488089211943 }, - { id: 6, frequency: 14, lastAccessedOn: 1493517292488 }, - { id: 7, frequency: 42, lastAccessedOn: 1486815299875 }, - { id: 8, frequency: 33, lastAccessedOn: 1500762279114 }, - { id: 10, frequency: 46, lastAccessedOn: 1483251641543 }, -]; - -/** - * This const has a specific order which tests authenticity - * of `ProjectsService.getTopFrequentProjects` method so - * DO NOT change order of items in this const. - */ -export const sortedFrequents = [ - { id: 10, frequency: 46, lastAccessedOn: 1483251641543 }, - { id: 3, frequency: 44, lastAccessedOn: 1497675908472 }, - { id: 7, frequency: 42, lastAccessedOn: 1486815299875 }, - { id: 5, frequency: 34, lastAccessedOn: 1488089211943 }, - { id: 8, frequency: 33, lastAccessedOn: 1500762279114 }, - { id: 6, frequency: 14, lastAccessedOn: 1493517292488 }, - { id: 2, frequency: 14, lastAccessedOn: 1488240890738 }, - { id: 1, frequency: 12, lastAccessedOn: 1491400843391 }, - { id: 4, frequency: 8, lastAccessedOn: 1497979281815 }, -]; diff --git a/spec/javascripts/projects_dropdown/service/projects_service_spec.js b/spec/javascripts/projects_dropdown/service/projects_service_spec.js deleted file mode 100644 index cfd1bb7d24f..00000000000 --- a/spec/javascripts/projects_dropdown/service/projects_service_spec.js +++ /dev/null @@ -1,179 +0,0 @@ -import Vue from 'vue'; -import VueResource from 'vue-resource'; - -import bp from '~/breakpoints'; -import ProjectsService from '~/projects_dropdown/service/projects_service'; -import { FREQUENT_PROJECTS } from '~/projects_dropdown/constants'; -import { currentSession, unsortedFrequents, sortedFrequents } from '../mock_data'; - -Vue.use(VueResource); - -FREQUENT_PROJECTS.MAX_COUNT = 3; - -describe('ProjectsService', () => { - let service; - - beforeEach(() => { - gon.api_version = currentSession.apiVersion; - gon.current_user_id = 1; - service = new ProjectsService(currentSession.username); - }); - - describe('contructor', () => { - it('should initialize default properties of class', () => { - expect(service.isLocalStorageAvailable).toBeTruthy(); - expect(service.currentUserName).toBe(currentSession.username); - expect(service.storageKey).toBe(currentSession.storageKey); - expect(service.projectsPath).toBeDefined(); - }); - }); - - describe('getSearchedProjects', () => { - it('should return promise from VueResource HTTP GET', () => { - spyOn(service.projectsPath, 'get').and.stub(); - - const searchQuery = 'lab'; - const queryParams = { - simple: true, - per_page: 20, - membership: true, - order_by: 'last_activity_at', - search: searchQuery, - }; - - service.getSearchedProjects(searchQuery); - expect(service.projectsPath.get).toHaveBeenCalledWith(queryParams); - }); - }); - - describe('logProjectAccess', () => { - let storage; - - beforeEach(() => { - storage = {}; - - spyOn(window.localStorage, 'setItem').and.callFake((storageKey, value) => { - storage[storageKey] = value; - }); - - spyOn(window.localStorage, 'getItem').and.callFake((storageKey) => { - if (storage[storageKey]) { - return storage[storageKey]; - } - - return null; - }); - }); - - it('should create a project store if it does not exist and adds a project', () => { - service.logProjectAccess(currentSession.project); - - const projects = JSON.parse(storage[currentSession.storageKey]); - expect(projects.length).toBe(1); - expect(projects[0].frequency).toBe(1); - expect(projects[0].lastAccessedOn).toBeDefined(); - }); - - it('should prevent inserting same report multiple times into store', () => { - service.logProjectAccess(currentSession.project); - service.logProjectAccess(currentSession.project); - - const projects = JSON.parse(storage[currentSession.storageKey]); - expect(projects.length).toBe(1); - }); - - it('should increase frequency of report if it was logged multiple times over the course of an hour', () => { - let projects; - spyOn(Math, 'abs').and.returnValue(3600001); // this will lead to `diff` > 1; - service.logProjectAccess(currentSession.project); - - projects = JSON.parse(storage[currentSession.storageKey]); - expect(projects[0].frequency).toBe(1); - - service.logProjectAccess(currentSession.project); - projects = JSON.parse(storage[currentSession.storageKey]); - expect(projects[0].frequency).toBe(2); - expect(projects[0].lastAccessedOn).not.toBe(currentSession.project.lastAccessedOn); - }); - - it('should always update project metadata', () => { - let projects; - const oldProject = { - ...currentSession.project, - }; - - const newProject = { - ...currentSession.project, - name: 'New Name', - avatarUrl: 'new/avatar.png', - namespace: 'New / Namespace', - webUrl: 'http://localhost/new/web/url', - }; - - service.logProjectAccess(oldProject); - projects = JSON.parse(storage[currentSession.storageKey]); - expect(projects[0].name).toBe(oldProject.name); - expect(projects[0].avatarUrl).toBe(oldProject.avatarUrl); - expect(projects[0].namespace).toBe(oldProject.namespace); - expect(projects[0].webUrl).toBe(oldProject.webUrl); - - service.logProjectAccess(newProject); - projects = JSON.parse(storage[currentSession.storageKey]); - expect(projects[0].name).toBe(newProject.name); - expect(projects[0].avatarUrl).toBe(newProject.avatarUrl); - expect(projects[0].namespace).toBe(newProject.namespace); - expect(projects[0].webUrl).toBe(newProject.webUrl); - }); - - it('should not add more than 20 projects in store', () => { - for (let i = 1; i <= 5; i += 1) { - const project = Object.assign(currentSession.project, { id: i }); - service.logProjectAccess(project); - } - - const projects = JSON.parse(storage[currentSession.storageKey]); - expect(projects.length).toBe(3); - }); - }); - - describe('getTopFrequentProjects', () => { - let storage = {}; - - beforeEach(() => { - storage[currentSession.storageKey] = JSON.stringify(unsortedFrequents); - - spyOn(window.localStorage, 'getItem').and.callFake((storageKey) => { - if (storage[storageKey]) { - return storage[storageKey]; - } - - return null; - }); - }); - - it('should return top 5 frequently accessed projects for desktop screens', () => { - spyOn(bp, 'getBreakpointSize').and.returnValue('md'); - const frequentProjects = service.getTopFrequentProjects(); - - expect(frequentProjects.length).toBe(5); - frequentProjects.forEach((project, index) => { - expect(project.id).toBe(sortedFrequents[index].id); - }); - }); - - it('should return top 3 frequently accessed projects for mobile screens', () => { - spyOn(bp, 'getBreakpointSize').and.returnValue('sm'); - const frequentProjects = service.getTopFrequentProjects(); - - expect(frequentProjects.length).toBe(3); - frequentProjects.forEach((project, index) => { - expect(project.id).toBe(sortedFrequents[index].id); - }); - }); - - it('should return empty array if there are no projects available in store', () => { - storage = {}; - expect(service.getTopFrequentProjects().length).toBe(0); - }); - }); -}); diff --git a/spec/javascripts/projects_dropdown/store/projects_store_spec.js b/spec/javascripts/projects_dropdown/store/projects_store_spec.js deleted file mode 100644 index e57399d37cd..00000000000 --- a/spec/javascripts/projects_dropdown/store/projects_store_spec.js +++ /dev/null @@ -1,41 +0,0 @@ -import ProjectsStore from '~/projects_dropdown/store/projects_store'; -import { mockProject, mockRawProject } from '../mock_data'; - -describe('ProjectsStore', () => { - let store; - - beforeEach(() => { - store = new ProjectsStore(); - }); - - describe('setFrequentProjects', () => { - it('should set frequent projects list to state', () => { - store.setFrequentProjects([mockProject]); - - expect(store.getFrequentProjects().length).toBe(1); - expect(store.getFrequentProjects()[0].id).toBe(mockProject.id); - }); - }); - - describe('setSearchedProjects', () => { - it('should set searched projects list to state', () => { - store.setSearchedProjects([mockRawProject]); - - const processedProjects = store.getSearchedProjects(); - expect(processedProjects.length).toBe(1); - expect(processedProjects[0].id).toBe(mockRawProject.id); - expect(processedProjects[0].namespace).toBe(mockRawProject.name_with_namespace); - expect(processedProjects[0].webUrl).toBe(mockRawProject.web_url); - expect(processedProjects[0].avatarUrl).toBe(mockRawProject.avatar_url); - }); - }); - - describe('clearSearchedProjects', () => { - it('should clear searched projects list from state', () => { - store.setSearchedProjects([mockRawProject]); - expect(store.getSearchedProjects().length).toBe(1); - store.clearSearchedProjects(); - expect(store.getSearchedProjects().length).toBe(0); - }); - }); -}); |