Commit 89665b30 authored by Phil Hughes's avatar Phil Hughes

Merge branch '36234-nav-add-groups-dropdown' into 'master'

Resolve "Add dropdown to Groups link in top bar"

Closes #36234

See merge request gitlab-org/gitlab-ce!18280
parents b14b31b8 3892b022
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import AccessorUtilities from '~/lib/utils/accessor';
import eventHub from '../event_hub';
import store from '../store/';
import { FREQUENT_ITEMS, STORAGE_KEY } from '../constants';
import { isMobile, updateExistingFrequentItem } from '../utils';
import FrequentItemsSearchInput from './frequent_items_search_input.vue';
import FrequentItemsList from './frequent_items_list.vue';
import frequentItemsMixin from './frequent_items_mixin';
export default {
store,
components: {
LoadingIcon,
FrequentItemsSearchInput,
FrequentItemsList,
},
mixins: [frequentItemsMixin],
props: {
currentUserName: {
type: String,
required: true,
},
currentItem: {
type: Object,
required: true,
},
},
computed: {
...mapState(['searchQuery', 'isLoadingItems', 'isFetchFailed', 'items']),
...mapGetters(['hasSearchQuery']),
translations() {
return this.getTranslations(['loadingMessage', 'header']);
},
},
created() {
const { namespace, currentUserName, currentItem } = this;
const storageKey = `${currentUserName}/${STORAGE_KEY[namespace]}`;
this.setNamespace(namespace);
this.setStorageKey(storageKey);
if (currentItem.id) {
this.logItemAccess(storageKey, currentItem);
}
eventHub.$on(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler);
},
beforeDestroy() {
eventHub.$off(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler);
},
methods: {
...mapActions(['setNamespace', 'setStorageKey', 'fetchFrequentItems']),
dropdownOpenHandler() {
if (this.searchQuery === '' || isMobile()) {
this.fetchFrequentItems();
}
},
logItemAccess(storageKey, item) {
if (!AccessorUtilities.isLocalStorageAccessSafe()) {
return false;
}
// Check if there's any frequent items list set
const storedRawItems = localStorage.getItem(storageKey);
const storedFrequentItems = storedRawItems
? JSON.parse(storedRawItems)
: [{ ...item, frequency: 1 }]; // No frequent items list set, set one up.
// Check if item already exists in list
const itemMatchIndex = storedFrequentItems.findIndex(
frequentItem => frequentItem.id === item.id,
);
if (itemMatchIndex > -1) {
storedFrequentItems[itemMatchIndex] = updateExistingFrequentItem(
storedFrequentItems[itemMatchIndex],
item,
);
} else {
if (storedFrequentItems.length === FREQUENT_ITEMS.MAX_COUNT) {
storedFrequentItems.shift();
}
storedFrequentItems.push({ ...item, frequency: 1 });
}
return localStorage.setItem(storageKey, JSON.stringify(storedFrequentItems));
},
},
};
</script>
<template>
<div>
<frequent-items-search-input
:namespace="namespace"
/>
<loading-icon
v-if="isLoadingItems"
:label="translations.loadingMessage"
class="loading-animation prepend-top-20"
size="2"
/>
<div
v-if="!isLoadingItems && !hasSearchQuery"
class="section-header"
>
{{ translations.header }}
</div>
<frequent-items-list
v-if="!isLoadingItems"
:items="items"
:namespace="namespace"
:has-search-query="hasSearchQuery"
:is-fetch-failed="isFetchFailed"
:matcher="searchQuery"
/>
</div>
</template>
<script> <script>
import { s__ } from '../../locale'; import FrequentItemsListItem from './frequent_items_list_item.vue';
import projectsListItem from './projects_list_item.vue'; import frequentItemsMixin from './frequent_items_mixin';
export default { export default {
components: { components: {
projectsListItem, FrequentItemsListItem,
}, },
mixins: [frequentItemsMixin],
props: { props: {
matcher: { items: {
type: String, type: Array,
required: true, required: true,
}, },
projects: { hasSearchQuery: {
type: Array, type: Boolean,
required: true, required: true,
}, },
searchFailed: { isFetchFailed: {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
matcher: {
type: String,
required: true,
},
}, },
computed: { computed: {
translations() {
return this.getTranslations([
'itemListEmptyMessage',
'itemListErrorMessage',
'searchListEmptyMessage',
'searchListErrorMessage',
]);
},
isListEmpty() { isListEmpty() {
return this.projects.length === 0; return this.items.length === 0;
}, },
listEmptyMessage() { listEmptyMessage() {
return this.searchFailed ? if (this.hasSearchQuery) {
s__('ProjectsDropdown|Something went wrong on our end.') : return this.isFetchFailed
s__('ProjectsDropdown|Sorry, no projects matched your search'); ? this.translations.searchListErrorMessage
: this.translations.searchListEmptyMessage;
}
return this.isFetchFailed
? this.translations.itemListErrorMessage
: this.translations.itemListEmptyMessage;
}, },
}, },
}; };
</script> </script>
<template> <template>
<div <div class="frequent-items-list-container">
class="projects-list-search-container" <ul class="list-unstyled">
>
<ul
class="list-unstyled"
>
<li <li
v-if="isListEmpty" v-if="isListEmpty"
:class="{ 'section-failure': searchFailed }" :class="{ 'section-failure': isFetchFailed }"
class="section-empty" class="section-empty"
> >
{{ listEmptyMessage }} {{ listEmptyMessage }}
</li> </li>
<projects-list-item <frequent-items-list-item
v-for="(project, index) in projects" v-for="item in items"
v-else v-else
:key="index" :key="item.id"
:project-id="project.id" :item-id="item.id"
:project-name="project.name" :item-name="item.name"
:namespace="project.namespace" :namespace="item.namespace"
:web-url="project.webUrl" :web-url="item.webUrl"
:avatar-url="project.avatarUrl" :avatar-url="item.avatarUrl"
:matcher="matcher" :matcher="matcher"
/> />
</ul> </ul>
......
<script>
/* eslint-disable vue/require-default-prop, vue/require-prop-types */
import Identicon from '../../vue_shared/components/identicon.vue';
export default {
components: {
Identicon,
},
props: {
matcher: {
type: String,
required: false,
},
itemId: {
type: Number,
required: true,
},
itemName: {
type: String,
required: true,
},
namespace: {
type: String,
required: false,
},
webUrl: {
type: String,
required: true,
},
avatarUrl: {
required: true,
validator(value) {
return value === null || typeof value === 'string';
},
},
},
computed: {
hasAvatar() {
return this.avatarUrl !== null;
},
highlightedItemName() {
if (this.matcher) {
const matcherRegEx = new RegExp(this.matcher, 'gi');
const matches = this.itemName.match(matcherRegEx);
if (matches && matches.length > 0) {
return this.itemName.replace(matches[0], `<b>${matches[0]}</b>`);
}
}
return this.itemName;
},
/**
* Smartly truncates item namespace by doing two things;
* 1. Only include Group names in path by removing item name
* 2. Only include first and last group names in the path
* when namespace has more than 2 groups present
*
* First part (removal of item name from namespace) can be
* done from backend but doing so involves migration of
* existing item namespaces which is not wise thing to do.
*/
truncatedNamespace() {
if (!this.namespace) {
return null;
}
const namespaceArr = this.namespace.split(' / ');
namespaceArr.splice(-1, 1);
let namespace = namespaceArr.join(' / ');
if (namespaceArr.length > 2) {
namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`;
}
return namespace;
},
},
};
</script>
<template>
<li class="frequent-items-list-item-container">
<a
:href="webUrl"
class="clearfix"
>
<div class="frequent-items-item-avatar-container">
<img
v-if="hasAvatar"
:src="avatarUrl"
class="avatar s32"
/>
<identicon
v-else
:entity-id="itemId"
:entity-name="itemName"
size-class="s32"
/>
</div>
<div class="frequent-items-item-metadata-container">
<div
:title="itemName"
class="frequent-items-item-title"
v-html="highlightedItemName"
>
</div>
<div
v-if="truncatedNamespace"
:title="namespace"
class="frequent-items-item-namespace"
>
{{ truncatedNamespace }}
</div>
</div>
</a>
</li>
</template>
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;
},
},
};
<script>
import _ from 'underscore';
import { mapActions } from 'vuex';
import eventHub from '../event_hub';
import frequentItemsMixin from './frequent_items_mixin';
export default {
mixins: [frequentItemsMixin],
data() {
return {
searchQuery: '',
};
},
computed: {
translations() {
return this.getTranslations(['searchInputPlaceholder']);
},
},
watch: {
searchQuery: _.debounce(function debounceSearchQuery() {
this.setSearchQuery(this.searchQuery);
}, 500),
},
mounted() {
eventHub.$on(`${this.namespace}-dropdownOpen`, this.setFocus);
},
beforeDestroy() {
eventHub.$off(`${this.namespace}-dropdownOpen`, this.setFocus);
},
methods: {
...mapActions(['setSearchQuery']),
setFocus() {
this.$refs.search.focus();
},
},
};
</script>
<template>
<div class="search-input-container d-none d-sm-block">
<input
ref="search"
v-model="searchQuery"
:placeholder="translations.searchInputPlaceholder"
type="search"
class="form-control"
/>
<i
v-if="!searchQuery"
class="search-icon fa fa-fw fa-search"
aria-hidden="true"
>
</i>
</div>
</template>
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'),
},
};
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,
},
});
},
});
});
});
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 () => {};
export const hasSearchQuery = state => state.searchQuery !== '';
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
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(),
});
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';
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,
});
},
};
export default () => ({
namespace: '',
storageKey: '',
searchQuery: '',
isLoadingItems: false,
isFetchFailed: false,
items: [],
});
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,
};
};
...@@ -26,7 +26,7 @@ import './feature_highlight/feature_highlight_options'; ...@@ -26,7 +26,7 @@ import './feature_highlight/feature_highlight_options';
import LazyLoader from './lazy_loader'; import LazyLoader from './lazy_loader';
import initLogoAnimation from './logo'; import initLogoAnimation from './logo';
import './milestone_select'; import './milestone_select';
import './projects_dropdown'; import './frequent_items';
import initBreadcrumbs from './breadcrumb'; import initBreadcrumbs from './breadcrumb';
import initDispatcher from './dispatcher'; import initDispatcher from './dispatcher';
......
<script>
import bs from '../../breakpoints';
import eventHub from '../event_hub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import projectsListFrequent from './projects_list_frequent.vue';
import projectsListSearch from './projects_list_search.vue';
import search from './search.vue';
export default {
components: {
search,
loadingIcon,
projectsListFrequent,
projectsListSearch,
},
props: {
currentProject: {
type: Object,
required: true,
},
store: {
type: Object,
required: true,
},
service: {
type: Object,
required: true,
},
},
data() {
return {
isLoadingProjects: false,
isFrequentsListVisible: false,
isSearchListVisible: false,
isLocalStorageFailed: false,
isSearchFailed: false,
searchQuery: '',
};
},
computed: {
frequentProjects() {
return this.store.getFrequentProjects();
},
searchProjects() {
return this.store.getSearchedProjects();
},
},
created() {
if (this.currentProject.id) {
this.logCurrentProjectAccess();
}
eventHub.$on('dropdownOpen', this.fetchFrequentProjects);
eventHub.$on('searchProjects', this.fetchSearchedProjects);
eventHub.$on('searchCleared', this.handleSearchClear);
eventHub.$on('searchFailed', this.handleSearchFailure);
},
beforeDestroy() {
eventHub.$off('dropdownOpen', this.fetchFrequentProjects);
eventHub.$off('searchProjects', this.fetchSearchedProjects);
eventHub.$off('searchCleared', this.handleSearchClear);
eventHub.$off('searchFailed', this.handleSearchFailure);
},
methods: {
toggleFrequentProjectsList(state) {
this.isLoadingProjects = !state;
this.isSearchListVisible = !state;
this.isFrequentsListVisible = state;
},
toggleSearchProjectsList(state) {
this.isLoadingProjects = !state;
this.isFrequentsListVisible = !state;
this.isSearchListVisible = state;
},
toggleLoader(state) {
this.isFrequentsListVisible = !state;
this.isSearchListVisible = !state;
this.isLoadingProjects = state;
},
fetchFrequentProjects() {
const screenSize = bs.getBreakpointSize();
if (this.searchQuery && (screenSize !== 'sm' && screenSize !== 'xs')) {
this.toggleSearchProjectsList(true);
} else {
this.toggleLoader(true);
this.isLocalStorageFailed = false;
const projects = this.service.getFrequentProjects();
if (projects) {
this.toggleFrequentProjectsList(true);
this.store.setFrequentProjects(projects);
} else {
this.isLocalStorageFailed = true;
this.toggleFrequentProjectsList(true);
this.store.setFrequentProjects([]);
}
}
},
fetchSearchedProjects(searchQuery) {
this.searchQuery = searchQuery;
this.toggleLoader(true);
this.service
.getSearchedProjects(this.searchQuery)
.then(res => res.json())
.then(results => {
this.toggleSearchProjectsList(true);
this.store.setSearchedProjects(results);
})
.catch(() => {
this.isSearchFailed = true;
this.toggleSearchProjectsList(true);
});
},
logCurrentProjectAccess() {
this.service.logProjectAccess(this.currentProject);
},
handleSearchClear() {
this.searchQuery = '';
this.toggleFrequentProjectsList(true);
this.store.clearSearchedProjects();
},
handleSearchFailure() {
this.isSearchFailed = true;
this.toggleSearchProjectsList(true);
},
},
};
</script>
<template>
<div>
<search/>
<loading-icon
v-if="isLoadingProjects"
:label="s__('ProjectsDropdown|Loading projects')"
class="loading-animation prepend-top-20"
size="2"
/>
<div
v-if="isFrequentsListVisible"
class="section-header"
>
{{ s__('ProjectsDropdown|Frequently visited') }}
</div>
<projects-list-frequent
v-if="isFrequentsListVisible"
:local-storage-failed="isLocalStorageFailed"
:projects="frequentProjects"
/>
<projects-list-search
v-if="isSearchListVisible"
:search-failed="isSearchFailed"
:matcher="searchQuery"
:projects="searchProjects"
/>
</div>
</template>
<script>
import { s__ } from '../../locale';
import projectsListItem from './projects_list_item.vue';
export default {
components: {
projectsListItem,
},
props: {
projects: {
type: Array,
required: true,
},
localStorageFailed: {
type: Boolean,
required: true,
},
},
computed: {
isListEmpty() {
return this.projects.length === 0;
},
listEmptyMessage() {
return this.localStorageFailed ?
s__('ProjectsDropdown|This feature requires browser localStorage support') :
s__('ProjectsDropdown|Projects you visit often will appear here');
},
},
};
</script>
<template>
<div
class="projects-list-frequent-container"
>
<ul
class="list-unstyled"
>
<li
v-if="isListEmpty"
class="section-empty"
>
{{ listEmptyMessage }}
</li>
<projects-list-item
v-for="(project, index) in projects"
v-else
:key="index"
:project-id="project.id"
:project-name="project.name"
:namespace="project.namespace"
:web-url="project.webUrl"
:avatar-url="project.avatarUrl"
/>
</ul>
</div>
</template>
<script>
/* eslint-disable vue/require-default-prop, vue/require-prop-types */
import identicon from '../../vue_shared/components/identicon.vue';
export default {
components: {
identicon,
},
props: {
matcher: {
type: String,
required: false,
},
projectId: {
type: Number,
required: true,
},
projectName: {
type: String,
required: true,
},
namespace: {
type: String,
required: true,
},
webUrl: {
type: String,
required: true,
},
avatarUrl: {
required: true,
validator(value) {
return value === null || typeof value === 'string';
},
},
},
computed: {
hasAvatar() {
return this.avatarUrl !== null;
},
highlightedProjectName() {
if (this.matcher) {
const matcherRegEx = new RegExp(this.matcher, 'gi');
const matches = this.projectName.match(matcherRegEx);
if (matches && matches.length > 0) {
return this.projectName.replace(matches[0], `<b>${matches[0]}</b>`);
}
}
return this.projectName;
},
/**
* Smartly truncates project namespace by doing two things;
* 1. Only include Group names in path by removing project name
* 2. Only include first and last group names in the path
* when namespace has more than 2 groups present
*
* First part (removal of project name from namespace) can be
* done from backend but doing so involves migration of
* existing project namespaces which is not wise thing to do.
*/
truncatedNamespace() {
const namespaceArr = this.namespace.split(' / ');
namespaceArr.splice(-1, 1);
let namespace = namespaceArr.join(' / ');
if (namespaceArr.length > 2) {
namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`;
}
return namespace;
},
},
};
</script>
<template>
<li
class="projects-list-item-container"
>
<a
:href="webUrl"
class="clearfix"
>
<div
class="project-item-avatar-container"
>
<img
v-if="hasAvatar"
:src="avatarUrl"
class="avatar s32"
/>
<identicon
v-else
:entity-id="projectId"
:entity-name="projectName"
size-class="s32"
/>
</div>
<div
class="project-item-metadata-container"
>
<div
:title="projectName"
class="project-title"
v-html="highlightedProjectName"
>
</div>
<div
:title="namespace"
class="project-namespace"
>{{ truncatedNamespace }}</div>
</div>
</a>
</li>
</template>
<script>
import _ from 'underscore';
import eventHub from '../event_hub';
export default {
data() {
return {
searchQuery: '',
};
},
watch: {
searchQuery() {
this.handleInput();
},
},
mounted() {
eventHub.$on('dropdownOpen', this.setFocus);
},
beforeDestroy() {
eventHub.$off('dropdownOpen', this.setFocus);
},
methods: {
setFocus() {
this.$refs.search.focus();
},
emitSearchEvents() {
if (this.searchQuery) {
eventHub.$emit('searchProjects', this.searchQuery);
} else {
eventHub.$emit('searchCleared');
}
},
/**
* Callback function within _.debounce is intentionally
* kept as ES5 `function() {}` instead of ES6 `() => {}`
* as it otherwise messes up function context
* and component reference is no longer accessible via `this`
*/
// eslint-disable-next-line func-names
handleInput: _.debounce(function () {
this.emitSearchEvents();
}, 500),
},
};
</script>
<template>
<div
class="search-input-container d-none d-sm-block"
>
<input
ref="search"
v-model="searchQuery"
:placeholder="s__('ProjectsDropdown|Search your projects')"
type="search"
class="form-control"
/>
<i
v-if="!searchQuery"
class="search-icon fa fa-fw fa-search"
aria-hidden="true"
>
</i>
</div>
</template>
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';
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,
},
});
},
});
});
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);
}
}
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 = [];
}
}
...@@ -36,7 +36,7 @@ ...@@ -36,7 +36,7 @@
width: 100%; width: 100%;
} }
&.projects-dropdown-menu { &.frequent-items-dropdown-menu {
padding: 0; padding: 0;
overflow-y: initial; overflow-y: initial;
max-height: initial; max-height: initial;
...@@ -790,6 +790,7 @@ ...@@ -790,6 +790,7 @@
@include media-breakpoint-down(xs) { @include media-breakpoint-down(xs) {
.navbar-gitlab { .navbar-gitlab {
li.header-projects, li.header-projects,
li.header-groups,
li.header-more, li.header-more,
li.header-new, li.header-new,
li.header-user { li.header-user {
...@@ -813,18 +814,18 @@ ...@@ -813,18 +814,18 @@
} }
} }
header.header-content .dropdown-menu.projects-dropdown-menu { header.header-content .dropdown-menu.frequent-items-dropdown-menu {
padding: 0; padding: 0;
} }
.projects-dropdown-container { .frequent-items-dropdown-container {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
width: 500px; width: 500px;
height: 334px; height: 334px;
.project-dropdown-sidebar, .frequent-items-dropdown-sidebar,
.project-dropdown-content { .frequent-items-dropdown-content {
padding: 8px 0; padding: 8px 0;
} }
...@@ -832,12 +833,12 @@ header.header-content .dropdown-menu.projects-dropdown-menu { ...@@ -832,12 +833,12 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
color: $almost-black; color: $almost-black;
} }
.project-dropdown-sidebar { .frequent-items-dropdown-sidebar {
width: 30%; width: 30%;
border-right: 1px solid $border-color; border-right: 1px solid $border-color;
} }
.project-dropdown-content { .frequent-items-dropdown-content {
position: relative; position: relative;
width: 70%; width: 70%;
} }
...@@ -848,33 +849,35 @@ header.header-content .dropdown-menu.projects-dropdown-menu { ...@@ -848,33 +849,35 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
height: auto; height: auto;
flex: 1; flex: 1;
.project-dropdown-sidebar, .frequent-items-dropdown-sidebar,
.project-dropdown-content { .frequent-items-dropdown-content {
width: 100%; width: 100%;
} }
.project-dropdown-sidebar { .frequent-items-dropdown-sidebar {
border-bottom: 1px solid $border-color; border-bottom: 1px solid $border-color;
border-right: 0; border-right: 0;
} }
} }
.projects-list-frequent-container, .section-header,
.projects-list-search-container { .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; padding: 8px 0;
overflow-y: auto; overflow-y: auto;
li.section-empty.section-failure { li.section-empty.section-failure {
color: $callout-danger-color; color: $callout-danger-color;
} }
}
.section-header, .frequent-items-list-item-container a {
.projects-list-frequent-container li.section-empty, display: flex;
.projects-list-search-container li.section-empty { }
padding: 0 15px;
color: $gl-text-color-secondary;
font-size: $gl-font-size;
} }
.search-input-container { .search-input-container {
...@@ -894,12 +897,12 @@ header.header-content .dropdown-menu.projects-dropdown-menu { ...@@ -894,12 +897,12 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
margin-top: 8px; margin-top: 8px;
} }
.projects-list-search-container { .frequent-items-search-container {
height: 284px; height: 284px;
} }
@include media-breakpoint-down(xs) { @include media-breakpoint-down(xs) {
.projects-list-frequent-container { .frequent-items-list-container {
width: auto; width: auto;
height: auto; height: auto;
padding-bottom: 0; padding-bottom: 0;
...@@ -907,32 +910,38 @@ header.header-content .dropdown-menu.projects-dropdown-menu { ...@@ -907,32 +910,38 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
} }
} }
.projects-list-item-container { .frequent-items-list-item-container {
.project-item-avatar-container .project-item-metadata-container { .frequent-items-item-avatar-container,
.frequent-items-item-metadata-container {
float: left; float: left;
} }
.project-title, .frequent-items-item-metadata-container {
.project-namespace { display: flex;
flex-direction: column;
justify-content: center;
}
.frequent-items-item-title,
.frequent-items-item-namespace {
max-width: 250px; max-width: 250px;
overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
&:hover { &:hover {
.project-item-avatar-container .avatar { .frequent-items-item-avatar-container .avatar {
border-color: $md-area-border; border-color: $md-area-border;
} }
} }
.project-title { .frequent-items-item-title {
font-size: $gl-font-size; font-size: $gl-font-size;
font-weight: 400; font-weight: 400;
line-height: 16px; line-height: 16px;
} }
.project-namespace { .frequent-items-item-namespace {
margin-top: 4px; margin-top: 4px;
font-size: 12px; font-size: 12px;
line-height: 12px; line-height: 12px;
...@@ -940,7 +949,7 @@ header.header-content .dropdown-menu.projects-dropdown-menu { ...@@ -940,7 +949,7 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
} }
@include media-breakpoint-down(xs) { @include media-breakpoint-down(xs) {
.project-item-metadata-container { .frequent-items-item-metadata-container {
float: none; float: none;
} }
} }
......
...@@ -29,15 +29,21 @@ ...@@ -29,15 +29,21 @@
.navbar-sub-nav, .navbar-sub-nav,
.navbar-nav { .navbar-nav {
> li { > li {
> a:hover, > a,
> a:focus { > button {
background-color: rgba($search-and-nav-links, 0.2); &:hover,
&:focus {
background-color: rgba($search-and-nav-links, 0.2);
}
} }
&.active > a, &.active,
&.dropdown.show > a { &.dropdown.show {
color: $nav-svg-color; > a,
background-color: $color-alternate; > button {
color: $nav-svg-color;
background-color: $color-alternate;
}
} }
&.line-separator { &.line-separator {
...@@ -147,7 +153,6 @@ ...@@ -147,7 +153,6 @@
} }
} }
// Sidebar // Sidebar
.nav-sidebar li.active { .nav-sidebar li.active {
box-shadow: inset 4px 0 0 $border-and-box-shadow; box-shadow: inset 4px 0 0 $border-and-box-shadow;
......
...@@ -269,14 +269,8 @@ ...@@ -269,14 +269,8 @@
.navbar-sub-nav, .navbar-sub-nav,
.navbar-nav { .navbar-nav {
> li { > li {
> a:hover, > a,
> a:focus { > button {
text-decoration: none;
outline: 0;
color: $white-light;
}
> a {
display: -webkit-flex; display: -webkit-flex;
display: flex; display: flex;
align-items: center; align-items: center;
...@@ -288,6 +282,18 @@ ...@@ -288,6 +282,18 @@
border-radius: $border-radius-default; border-radius: $border-radius-default;
height: 32px; height: 32px;
font-weight: $gl-font-weight-bold; font-weight: $gl-font-weight-bold;
&:hover,
&:focus {
text-decoration: none;
outline: 0;
color: $white-light;
}
}
> button {
background: transparent;
border: 0;
} }
&.line-separator { &.line-separator {
...@@ -311,7 +317,7 @@ ...@@ -311,7 +317,7 @@
font-size: 10px; font-size: 10px;
} }
.project-item-select-holder { .frequent-items-item-select-holder {
display: inline; display: inline;
} }
......
%ul.list-unstyled.navbar-sub-nav %ul.list-unstyled.navbar-sub-nav
- if dashboard_nav_link?(:projects) - 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 = 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 Projects
= sprite_icon('angle-down', css_class: 'caret-down') = 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" = render "layouts/nav/projects_dropdown/show"
- if dashboard_nav_link?(:groups) - if dashboard_nav_link?(:groups)
= nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { class: "d-none d-sm-block" }) do = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { id: 'nav-groups-dropdown', class: "home dropdown header-groups qa-groups-dropdown" }) do
= link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups qa-groups-link', title: 'Groups' do %button{ type: 'button', data: { toggle: "dropdown" } }
Groups 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) - if dashboard_nav_link?(:activity)
= nav_link(path: 'dashboard#activity', html_options: { class: "d-none d-lg-block d-xl-block" }) do = nav_link(path: 'dashboard#activity', html_options: { class: "d-none d-lg-block d-xl-block" }) do
...@@ -34,11 +37,6 @@ ...@@ -34,11 +37,6 @@
= sprite_icon('angle-down', css_class: 'caret-down') = sprite_icon('angle-down', css_class: 'caret-down')
.dropdown-menu .dropdown-menu
%ul %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) - if dashboard_nav_link?(:activity)
= nav_link(path: 'dashboard#activity') do = nav_link(path: 'dashboard#activity') do
= link_to activity_dashboard_path, title: 'Activity' do = link_to activity_dashboard_path, title: 'Activity' do
......
- 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 } }
- 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? - 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 .frequent-items-dropdown-container
.project-dropdown-sidebar.qa-projects-dropdown-sidebar .frequent-items-dropdown-sidebar.qa-projects-dropdown-sidebar
%ul %ul
= nav_link(path: 'dashboard/projects#index') do = nav_link(path: 'dashboard/projects#index') do
= link_to dashboard_projects_path, class: 'qa-your-projects-link' do = link_to dashboard_projects_path, class: 'qa-your-projects-link' do
...@@ -11,5 +11,5 @@ ...@@ -11,5 +11,5 @@
= nav_link(path: 'projects#trending') do = nav_link(path: 'projects#trending') do
= link_to explore_root_path do = link_to explore_root_path do
= _('Explore projects') = _('Explore projects')
.project-dropdown-content .frequent-items-dropdown-content
#js-projects-dropdown{ data: { user_name: current_user.username, project: project_meta } } #js-projects-dropdown{ data: { user_name: current_user.username, project: project_meta } }
---
title: Add dropdown to Groups link in top bar
merge_request: 18280
author:
type: added
...@@ -16,7 +16,7 @@ module QA ...@@ -16,7 +16,7 @@ module QA
view 'app/views/layouts/nav/_dashboard.html.haml' do view 'app/views/layouts/nav/_dashboard.html.haml' do
element :admin_area_link element :admin_area_link
element :projects_dropdown element :projects_dropdown
element :groups_link element :groups_dropdown
end end
view 'app/views/layouts/nav/projects_dropdown/_show.html.haml' do view 'app/views/layouts/nav/projects_dropdown/_show.html.haml' do
...@@ -25,7 +25,13 @@ module QA ...@@ -25,7 +25,13 @@ module QA
end end
def go_to_groups 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 end
def go_to_projects def go_to_projects
......
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);
});
});
});
import Vue from 'vue'; import Vue from 'vue';
import frequentItemsListItemComponent from '~/frequent_items/components/frequent_items_list_item.vue';
import projectsListItemComponent from '~/projects_dropdown/components/projects_list_item.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockProject } from '../mock_data'; import { mockProject } from '../mock_data'; // can also use 'mockGroup', but not useful to test here
const createComponent = () => { const createComponent = () => {
const Component = Vue.extend(projectsListItemComponent); const Component = Vue.extend(frequentItemsListItemComponent);
return mountComponent(Component, { return mountComponent(Component, {
projectId: mockProject.id, itemId: mockProject.id,
projectName: mockProject.name, itemName: mockProject.name,
namespace: mockProject.namespace, namespace: mockProject.namespace,
webUrl: mockProject.webUrl, webUrl: mockProject.webUrl,
avatarUrl: mockProject.avatarUrl, avatarUrl: mockProject.avatarUrl,
}); });
}; };
describe('ProjectsListItemComponent', () => { describe('FrequentItemsListItemComponent', () => {
let vm; let vm;
beforeEach(() => { beforeEach(() => {
...@@ -32,22 +30,22 @@ describe('ProjectsListItemComponent', () => { ...@@ -32,22 +30,22 @@ describe('ProjectsListItemComponent', () => {
describe('hasAvatar', () => { describe('hasAvatar', () => {
it('should return `true` or `false` if whether avatar is present or not', () => { it('should return `true` or `false` if whether avatar is present or not', () => {
vm.avatarUrl = 'path/to/avatar.png'; vm.avatarUrl = 'path/to/avatar.png';
expect(vm.hasAvatar).toBeTruthy(); expect(vm.hasAvatar).toBe(true);
vm.avatarUrl = null; vm.avatarUrl = null;
expect(vm.hasAvatar).toBeFalsy(); expect(vm.hasAvatar).toBe(false);
}); });
}); });
describe('highlightedProjectName', () => { describe('highlightedItemName', () => {
it('should enclose part of project name in <b> & </b> which matches with `matcher` prop', () => { it('should enclose part of project name in <b> & </b> which matches with `matcher` prop', () => {
vm.matcher = 'lab'; vm.matcher = 'lab';
expect(vm.highlightedProjectName).toContain('<b>Lab</b>'); expect(vm.highlightedItemName).toContain('<b>Lab</b>');
}); });
it('should return project name as it is if `matcher` is not available', () => { it('should return project name as it is if `matcher` is not available', () => {
vm.matcher = null; vm.matcher = null;
expect(vm.highlightedProjectName).toBe(mockProject.name); expect(vm.highlightedItemName).toBe(mockProject.name);
}); });
}); });
...@@ -66,12 +64,12 @@ describe('ProjectsListItemComponent', () => { ...@@ -66,12 +64,12 @@ describe('ProjectsListItemComponent', () => {
describe('template', () => { describe('template', () => {
it('should render component element', () => { it('should render component element', () => {
expect(vm.$el.classList.contains('projects-list-item-container')).toBeTruthy(); expect(vm.$el.classList.contains('frequent-items-list-item-container')).toBeTruthy();
expect(vm.$el.querySelectorAll('a').length).toBe(1); expect(vm.$el.querySelectorAll('a').length).toBe(1);
expect(vm.$el.querySelectorAll('.project-item-avatar-container').length).toBe(1); expect(vm.$el.querySelectorAll('.frequent-items-item-avatar-container').length).toBe(1);
expect(vm.$el.querySelectorAll('.project-item-metadata-container').length).toBe(1); expect(vm.$el.querySelectorAll('.frequent-items-item-metadata-container').length).toBe(1);
expect(vm.$el.querySelectorAll('.project-title').length).toBe(1); expect(vm.$el.querySelectorAll('.frequent-items-item-title').length).toBe(1);
expect(vm.$el.querySelectorAll('.project-namespace').length).toBe(1); expect(vm.$el.querySelectorAll('.frequent-items-item-namespace').length).toBe(1);
}); });
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import frequentItemsListComponent from '~/frequent_items/components/frequent_items_list.vue';
import projectsListSearchComponent from '~/projects_dropdown/components/projects_list_search.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockProject } from '../mock_data'; import { mockFrequentProjects } from '../mock_data';
const createComponent = () => { const createComponent = (namespace = 'projects') => {
const Component = Vue.extend(projectsListSearchComponent); const Component = Vue.extend(frequentItemsListComponent);
return mountComponent(Component, { return mountComponent(Component, {
projects: [mockProject], namespace,
items: mockFrequentProjects,
isFetchFailed: false,
hasSearchQuery: false,
matcher: 'lab', matcher: 'lab',
searchFailed: false,
}); });
}; };
describe('ProjectsListSearchComponent', () => { describe('FrequentItemsListComponent', () => {
let vm; let vm;
beforeEach(() => { beforeEach(() => {
...@@ -28,55 +28,55 @@ describe('ProjectsListSearchComponent', () => { ...@@ -28,55 +28,55 @@ describe('ProjectsListSearchComponent', () => {
describe('computed', () => { describe('computed', () => {
describe('isListEmpty', () => { describe('isListEmpty', () => {
it('should return `true` or `false` representing whether if `projects` is empty of not', () => { it('should return `true` or `false` representing whether if `items` is empty or not with projects', () => {
vm.projects = []; vm.items = [];
expect(vm.isListEmpty).toBeTruthy(); 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.projects = [mockProject]; vm.isFetchFailed = false;
expect(vm.isListEmpty).toBeFalsy(); expect(vm.listEmptyMessage).toBe('Projects you visit often will appear here');
}); });
}); });
describe('listEmptyMessage', () => { describe('searched item messages', () => {
it('should return appropriate empty list message based on value of `searchFailed` prop', () => { it('should return appropriate empty list message based on value of `searchFailed` prop with projects', () => {
vm.searchFailed = true; vm.hasSearchQuery = true;
vm.isFetchFailed = true;
expect(vm.listEmptyMessage).toBe('Something went wrong on our end.'); expect(vm.listEmptyMessage).toBe('Something went wrong on our end.');
vm.searchFailed = false; vm.isFetchFailed = false;
expect(vm.listEmptyMessage).toBe('Sorry, no projects matched your search'); expect(vm.listEmptyMessage).toBe('Sorry, no projects matched your search');
}); });
}); });
}); });
describe('template', () => { describe('template', () => {
it('should render component element with list of projects', (done) => { it('should render component element with list of projects', done => {
vm.projects = [mockProject]; vm.items = mockFrequentProjects;
Vue.nextTick(() => { Vue.nextTick(() => {
expect(vm.$el.classList.contains('projects-list-search-container')).toBeTruthy(); 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('ul.list-unstyled').length).toBe(1);
expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(1); expect(vm.$el.querySelectorAll('li.frequent-items-list-item-container').length).toBe(5);
done(); done();
}); });
}); });
it('should render component element with empty message', (done) => { it('should render component element with empty message', done => {
vm.projects = []; vm.items = [];
Vue.nextTick(() => { Vue.nextTick(() => {
expect(vm.$el.querySelectorAll('li.section-empty').length).toBe(1); expect(vm.$el.querySelectorAll('li.section-empty').length).toBe(1);
expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(0); expect(vm.$el.querySelectorAll('li.frequent-items-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(); done();
}); });
}); });
......
import Vue from 'vue'; import Vue from 'vue';
import searchComponent from '~/frequent_items/components/frequent_items_search_input.vue';
import searchComponent from '~/projects_dropdown/components/search.vue'; import eventHub from '~/frequent_items/event_hub';
import eventHub from '~/projects_dropdown/event_hub';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
const createComponent = () => { const createComponent = (namespace = 'projects') => {
const Component = Vue.extend(searchComponent); const Component = Vue.extend(searchComponent);
return mountComponent(Component); return mountComponent(Component, { namespace });
}; };
describe('SearchComponent', () => { describe('FrequentItemsSearchInputComponent', () => {
describe('methods', () => { let vm;
let vm;
beforeEach(() => { beforeEach(() => {
vm = createComponent(); vm = createComponent();
}); });
afterEach(() => { afterEach(() => {
vm.$destroy(); vm.$destroy();
}); });
describe('methods', () => {
describe('setFocus', () => { describe('setFocus', () => {
it('should set focus to search input', () => { it('should set focus to search input', () => {
spyOn(vm.$refs.search, 'focus'); spyOn(vm.$refs.search, 'focus');
...@@ -31,63 +29,42 @@ describe('SearchComponent', () => { ...@@ -31,63 +29,42 @@ describe('SearchComponent', () => {
expect(vm.$refs.search.focus).toHaveBeenCalled(); 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', () => { describe('mounted', () => {
it('should listen `dropdownOpen` event', (done) => { it('should listen `dropdownOpen` event', done => {
spyOn(eventHub, '$on'); spyOn(eventHub, '$on');
createComponent(); const vmX = createComponent();
Vue.nextTick(() => { Vue.nextTick(() => {
expect(eventHub.$on).toHaveBeenCalledWith('dropdownOpen', jasmine.any(Function)); expect(eventHub.$on).toHaveBeenCalledWith(
`${vmX.namespace}-dropdownOpen`,
jasmine.any(Function),
);
done(); done();
}); });
}); });
}); });
describe('beforeDestroy', () => { describe('beforeDestroy', () => {
it('should unbind event listeners on eventHub', (done) => { it('should unbind event listeners on eventHub', done => {
const vm = createComponent(); const vmX = createComponent();
spyOn(eventHub, '$off'); spyOn(eventHub, '$off');
vm.$mount(); vmX.$mount();
vm.$destroy(); vmX.$destroy();
Vue.nextTick(() => { Vue.nextTick(() => {
expect(eventHub.$off).toHaveBeenCalledWith('dropdownOpen', jasmine.any(Function)); expect(eventHub.$off).toHaveBeenCalledWith(
`${vmX.namespace}-dropdownOpen`,
jasmine.any(Function),
);
done(); done();
}); });
}); });
}); });
describe('template', () => { describe('template', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
it('should render component element', () => { it('should render component element', () => {
const inputEl = vm.$el.querySelector('input.form-control'); const inputEl = vm.$el.querySelector('input.form-control');
......
export const currentSession = { export const currentSession = {
username: 'root', groups: {
storageKey: 'root/frequent-projects', username: 'root',
apiVersion: 'v4', storageKey: 'root/frequent-groups',
project: { 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, id: 1,
name: 'dummy-project', name: 'Commit451',
namespace: 'SamepleGroup / Dummy-Project', full_name: 'Commit451',
webUrl: 'http://127.0.0.1/samplegroup/dummy-project', webUrl: '/Commit451',
avatarUrl: null, avatarUrl: null,
lastAccessedOn: Date.now(), frequency: 3,
lastAccessedOn: 1497979281815,
}, },
}; ];
export const mockSearchedGroups = [mockRawGroup];
export const mockProcessedSearchedGroups = [mockGroup];
export const mockProject = { export const mockProject = {
id: 1, id: 1,
name: 'GitLab Community Edition', name: 'GitLab Community Edition',
namespace: 'gitlab-org / gitlab-ce', namespace: 'gitlab-org / gitlab-ce',
webUrl: 'http://127.0.0.1:3000/gitlab-org/gitlab-ce', webUrl: `${gl.TEST_HOST}/gitlab-org/gitlab-ce`,
avatarUrl: null, avatarUrl: null,
}; };
...@@ -24,49 +83,62 @@ export const mockRawProject = { ...@@ -24,49 +83,62 @@ export const mockRawProject = {
id: 1, id: 1,
name: 'GitLab Community Edition', name: 'GitLab Community Edition',
name_with_namespace: 'gitlab-org / gitlab-ce', name_with_namespace: 'gitlab-org / gitlab-ce',
web_url: 'http://127.0.0.1:3000/gitlab-org/gitlab-ce', web_url: `${gl.TEST_HOST}/gitlab-org/gitlab-ce`,
avatar_url: null, avatar_url: null,
}; };
export const mockFrequents = [ export const mockFrequentProjects = [
{ {
id: 1, id: 1,
name: 'GitLab Community Edition', name: 'GitLab Community Edition',
namespace: 'gitlab-org / gitlab-ce', namespace: 'gitlab-org / gitlab-ce',
webUrl: 'http://127.0.0.1:3000/gitlab-org/gitlab-ce', webUrl: `${gl.TEST_HOST}/gitlab-org/gitlab-ce`,
avatarUrl: null, avatarUrl: null,
frequency: 1,
lastAccessedOn: Date.now(),
}, },
{ {
id: 2, id: 2,
name: 'GitLab CI', name: 'GitLab CI',
namespace: 'gitlab-org / gitlab-ci', namespace: 'gitlab-org / gitlab-ci',
webUrl: 'http://127.0.0.1:3000/gitlab-org/gitlab-ci', webUrl: `${gl.TEST_HOST}/gitlab-org/gitlab-ci`,
avatarUrl: null, avatarUrl: null,
frequency: 9,
lastAccessedOn: Date.now(),
}, },
{ {
id: 3, id: 3,
name: 'Typeahead.Js', name: 'Typeahead.Js',
namespace: 'twitter / typeahead-js', namespace: 'twitter / typeahead-js',
webUrl: 'http://127.0.0.1:3000/twitter/typeahead-js', webUrl: `${gl.TEST_HOST}/twitter/typeahead-js`,
avatarUrl: '/uploads/-/system/project/avatar/7/TWBS.png', avatarUrl: '/uploads/-/system/project/avatar/7/TWBS.png',
frequency: 2,
lastAccessedOn: Date.now(),
}, },
{ {
id: 4, id: 4,
name: 'Intel', name: 'Intel',
namespace: 'platform / hardware / bsp / intel', namespace: 'platform / hardware / bsp / intel',
webUrl: 'http://127.0.0.1:3000/platform/hardware/bsp/intel', webUrl: `${gl.TEST_HOST}/platform/hardware/bsp/intel`,
avatarUrl: null, avatarUrl: null,
frequency: 3,
lastAccessedOn: Date.now(),
}, },
{ {
id: 5, id: 5,
name: 'v4.4', name: 'v4.4',
namespace: 'platform / hardware / bsp / kernel / common / v4.4', namespace: 'platform / hardware / bsp / kernel / common / v4.4',
webUrl: 'http://localhost:3000/platform/hardware/bsp/kernel/common/v4.4', webUrl: `${gl.TEST_HOST}/platform/hardware/bsp/kernel/common/v4.4`,
avatarUrl: null, avatarUrl: null,
frequency: 8,
lastAccessedOn: Date.now(),
}, },
]; ];
export const unsortedFrequents = [ export const mockSearchedProjects = [mockRawProject];
export const mockProcessedSearchedProjects = [mockProject];
export const unsortedFrequentItems = [
{ id: 1, frequency: 12, lastAccessedOn: 1491400843391 }, { id: 1, frequency: 12, lastAccessedOn: 1491400843391 },
{ id: 2, frequency: 14, lastAccessedOn: 1488240890738 }, { id: 2, frequency: 14, lastAccessedOn: 1488240890738 },
{ id: 3, frequency: 44, lastAccessedOn: 1497675908472 }, { id: 3, frequency: 44, lastAccessedOn: 1497675908472 },
...@@ -80,10 +152,10 @@ export const unsortedFrequents = [ ...@@ -80,10 +152,10 @@ export const unsortedFrequents = [
/** /**
* This const has a specific order which tests authenticity * This const has a specific order which tests authenticity
* of `ProjectsService.getTopFrequentProjects` method so * of `getTopFrequentItems` method so
* DO NOT change order of items in this const. * DO NOT change order of items in this const.
*/ */
export const sortedFrequents = [ export const sortedFrequentItems = [
{ id: 10, frequency: 46, lastAccessedOn: 1483251641543 }, { id: 10, frequency: 46, lastAccessedOn: 1483251641543 },
{ id: 3, frequency: 44, lastAccessedOn: 1497675908472 }, { id: 3, frequency: 44, lastAccessedOn: 1497675908472 },
{ id: 7, frequency: 42, lastAccessedOn: 1486815299875 }, { id: 7, frequency: 42, lastAccessedOn: 1486815299875 },
......
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,
);
});
});
});
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);
});
});
});
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);
});
});
});
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);
});
});
});
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();
});
});
});
});
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);
});
});
});
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);
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment