From 3892b022e3173851f418e4bd8469f0dcdde2ebef Mon Sep 17 00:00:00 2001 From: Dennis Tang Date: Fri, 6 Jul 2018 13:40:11 +0000 Subject: Resolve "Add dropdown to Groups link in top bar" --- .../javascripts/frequent_items/components/app.vue | 122 +++++++ .../components/frequent_items_list.vue | 78 +++++ .../components/frequent_items_list_item.vue | 117 +++++++ .../components/frequent_items_mixin.js | 23 ++ .../components/frequent_items_search_input.vue | 55 ++++ app/assets/javascripts/frequent_items/constants.js | 38 +++ app/assets/javascripts/frequent_items/event_hub.js | 3 + app/assets/javascripts/frequent_items/index.js | 69 ++++ .../javascripts/frequent_items/store/actions.js | 81 +++++ .../javascripts/frequent_items/store/getters.js | 4 + .../javascripts/frequent_items/store/index.js | 16 + .../frequent_items/store/mutation_types.js | 9 + .../javascripts/frequent_items/store/mutations.js | 71 +++++ .../javascripts/frequent_items/store/state.js | 8 + app/assets/javascripts/frequent_items/utils.js | 49 +++ app/assets/javascripts/main.js | 2 +- .../projects_dropdown/components/app.vue | 158 ---------- .../components/projects_list_frequent.vue | 57 ---- .../components/projects_list_item.vue | 116 ------- .../components/projects_list_search.vue | 63 ---- .../projects_dropdown/components/search.vue | 65 ---- .../javascripts/projects_dropdown/constants.js | 10 - .../javascripts/projects_dropdown/event_hub.js | 3 - app/assets/javascripts/projects_dropdown/index.js | 66 ---- .../projects_dropdown/service/projects_service.js | 137 -------- .../projects_dropdown/store/projects_store.js | 33 -- app/assets/stylesheets/framework/dropdowns.scss | 69 ++-- app/assets/stylesheets/framework/gitlab_theme.scss | 21 +- app/assets/stylesheets/framework/header.scss | 24 +- app/views/layouts/nav/_dashboard.html.haml | 16 +- .../layouts/nav/groups_dropdown/_show.html.haml | 12 + .../layouts/nav/projects_dropdown/_show.html.haml | 6 +- .../unreleased/36234-nav-add-groups-dropdown.yml | 5 + qa/qa/page/menu/main.rb | 10 +- .../frequent_items/components/app_spec.js | 251 +++++++++++++++ .../components/frequent_items_list_item_spec.js | 75 +++++ .../components/frequent_items_list_spec.js | 84 +++++ .../components/frequent_items_search_input_spec.js | 77 +++++ spec/javascripts/frequent_items/mock_data.js | 168 ++++++++++ .../frequent_items/store/actions_spec.js | 225 +++++++++++++ .../frequent_items/store/getters_spec.js | 24 ++ .../frequent_items/store/mutations_spec.js | 117 +++++++ spec/javascripts/frequent_items/utils_spec.js | 89 ++++++ .../projects_dropdown/components/app_spec.js | 349 --------------------- .../components/projects_list_frequent_spec.js | 72 ----- .../components/projects_list_item_spec.js | 77 ----- .../components/projects_list_search_spec.js | 84 ----- .../projects_dropdown/components/search_spec.js | 100 ------ spec/javascripts/projects_dropdown/mock_data.js | 96 ------ .../service/projects_service_spec.js | 179 ----------- .../projects_dropdown/store/projects_store_spec.js | 41 --- 51 files changed, 1956 insertions(+), 1768 deletions(-) create mode 100644 app/assets/javascripts/frequent_items/components/app.vue create mode 100644 app/assets/javascripts/frequent_items/components/frequent_items_list.vue create mode 100644 app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue create mode 100644 app/assets/javascripts/frequent_items/components/frequent_items_mixin.js create mode 100644 app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue create mode 100644 app/assets/javascripts/frequent_items/constants.js create mode 100644 app/assets/javascripts/frequent_items/event_hub.js create mode 100644 app/assets/javascripts/frequent_items/index.js create mode 100644 app/assets/javascripts/frequent_items/store/actions.js create mode 100644 app/assets/javascripts/frequent_items/store/getters.js create mode 100644 app/assets/javascripts/frequent_items/store/index.js create mode 100644 app/assets/javascripts/frequent_items/store/mutation_types.js create mode 100644 app/assets/javascripts/frequent_items/store/mutations.js create mode 100644 app/assets/javascripts/frequent_items/store/state.js create mode 100644 app/assets/javascripts/frequent_items/utils.js delete mode 100644 app/assets/javascripts/projects_dropdown/components/app.vue delete mode 100644 app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue delete mode 100644 app/assets/javascripts/projects_dropdown/components/projects_list_item.vue delete mode 100644 app/assets/javascripts/projects_dropdown/components/projects_list_search.vue delete mode 100644 app/assets/javascripts/projects_dropdown/components/search.vue delete mode 100644 app/assets/javascripts/projects_dropdown/constants.js delete mode 100644 app/assets/javascripts/projects_dropdown/event_hub.js delete mode 100644 app/assets/javascripts/projects_dropdown/index.js delete mode 100644 app/assets/javascripts/projects_dropdown/service/projects_service.js delete mode 100644 app/assets/javascripts/projects_dropdown/store/projects_store.js create mode 100644 app/views/layouts/nav/groups_dropdown/_show.html.haml create mode 100644 changelogs/unreleased/36234-nav-add-groups-dropdown.yml create mode 100644 spec/javascripts/frequent_items/components/app_spec.js create mode 100644 spec/javascripts/frequent_items/components/frequent_items_list_item_spec.js create mode 100644 spec/javascripts/frequent_items/components/frequent_items_list_spec.js create mode 100644 spec/javascripts/frequent_items/components/frequent_items_search_input_spec.js create mode 100644 spec/javascripts/frequent_items/mock_data.js create mode 100644 spec/javascripts/frequent_items/store/actions_spec.js create mode 100644 spec/javascripts/frequent_items/store/getters_spec.js create mode 100644 spec/javascripts/frequent_items/store/mutations_spec.js create mode 100644 spec/javascripts/frequent_items/utils_spec.js delete mode 100644 spec/javascripts/projects_dropdown/components/app_spec.js delete mode 100644 spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js delete mode 100644 spec/javascripts/projects_dropdown/components/projects_list_item_spec.js delete mode 100644 spec/javascripts/projects_dropdown/components/projects_list_search_spec.js delete mode 100644 spec/javascripts/projects_dropdown/components/search_spec.js delete mode 100644 spec/javascripts/projects_dropdown/mock_data.js delete mode 100644 spec/javascripts/projects_dropdown/service/projects_service_spec.js delete mode 100644 spec/javascripts/projects_dropdown/store/projects_store_spec.js 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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/frequent_items/event_hub.js b/app/assets/javascripts/frequent_items/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/frequent_items/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); 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 @@ - - - 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 @@ - - - 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 @@ - - - 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 @@ - - - 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 @@ - - - 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/event_hub.js b/app/assets/javascripts/projects_dropdown/event_hub.js deleted file mode 100644 index 0948c2e5352..00000000000 --- a/app/assets/javascripts/projects_dropdown/event_hub.js +++ /dev/null @@ -1,3 +0,0 @@ -import Vue from 'vue'; - -export default new Vue(); 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/frequent_items/components/frequent_items_list_item_spec.js b/spec/javascripts/frequent_items/components/frequent_items_list_item_spec.js new file mode 100644 index 00000000000..201aca77b10 --- /dev/null +++ b/spec/javascripts/frequent_items/components/frequent_items_list_item_spec.js @@ -0,0 +1,75 @@ +import Vue from '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'; // can also use 'mockGroup', but not useful to test here + +const createComponent = () => { + const Component = Vue.extend(frequentItemsListItemComponent); + + return mountComponent(Component, { + itemId: mockProject.id, + itemName: mockProject.name, + namespace: mockProject.namespace, + webUrl: mockProject.webUrl, + avatarUrl: mockProject.avatarUrl, + }); +}; + +describe('FrequentItemsListItemComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('hasAvatar', () => { + it('should return `true` or `false` if whether avatar is present or not', () => { + vm.avatarUrl = 'path/to/avatar.png'; + expect(vm.hasAvatar).toBe(true); + + vm.avatarUrl = null; + expect(vm.hasAvatar).toBe(false); + }); + }); + + describe('highlightedItemName', () => { + it('should enclose part of project name in & which matches with `matcher` prop', () => { + vm.matcher = 'lab'; + expect(vm.highlightedItemName).toContain('Lab'); + }); + + it('should return project name as it is if `matcher` is not available', () => { + vm.matcher = null; + expect(vm.highlightedItemName).toBe(mockProject.name); + }); + }); + + describe('truncatedNamespace', () => { + it('should truncate project name from namespace string', () => { + vm.namespace = 'platform / nokia-3310'; + expect(vm.truncatedNamespace).toBe('platform'); + }); + + it('should truncate namespace string from the middle if it includes more than two groups in path', () => { + vm.namespace = 'platform / hardware / broadcom / Wifi Group / Mobile Chipset / nokia-3310'; + expect(vm.truncatedNamespace).toBe('platform / ... / Mobile Chipset'); + }); + }); + }); + + describe('template', () => { + it('should render component element', () => { + expect(vm.$el.classList.contains('frequent-items-list-item-container')).toBeTruthy(); + expect(vm.$el.querySelectorAll('a').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_item_spec.js b/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js deleted file mode 100644 index c193258474e..00000000000 --- a/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js +++ /dev/null @@ -1,77 +0,0 @@ -import Vue from 'vue'; - -import projectsListItemComponent from '~/projects_dropdown/components/projects_list_item.vue'; - -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import { mockProject } from '../mock_data'; - -const createComponent = () => { - const Component = Vue.extend(projectsListItemComponent); - - return mountComponent(Component, { - projectId: mockProject.id, - projectName: mockProject.name, - namespace: mockProject.namespace, - webUrl: mockProject.webUrl, - avatarUrl: mockProject.avatarUrl, - }); -}; - -describe('ProjectsListItemComponent', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('computed', () => { - 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(); - - vm.avatarUrl = null; - expect(vm.hasAvatar).toBeFalsy(); - }); - }); - - describe('highlightedProjectName', () => { - it('should enclose part of project name in & which matches with `matcher` prop', () => { - vm.matcher = 'lab'; - expect(vm.highlightedProjectName).toContain('Lab'); - }); - - it('should return project name as it is if `matcher` is not available', () => { - vm.matcher = null; - expect(vm.highlightedProjectName).toBe(mockProject.name); - }); - }); - - describe('truncatedNamespace', () => { - it('should truncate project name from namespace string', () => { - vm.namespace = 'platform / nokia-3310'; - expect(vm.truncatedNamespace).toBe('platform'); - }); - - it('should truncate namespace string from the middle if it includes more than two groups in path', () => { - vm.namespace = 'platform / hardware / broadcom / Wifi Group / Mobile Chipset / nokia-3310'; - expect(vm.truncatedNamespace).toBe('platform / ... / Mobile Chipset'); - }); - }); - }); - - describe('template', () => { - it('should render component element', () => { - expect(vm.$el.classList.contains('projects-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); - }); - }); -}); 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); - }); - }); -}); -- cgit v1.2.1