Commit e60f1769 authored by Grzegorz Bizon's avatar Grzegorz Bizon

Merge branch 'master' into 'ce-to-ee-2018-07-06'

# Conflicts:
#   db/schema.rb
parents 8cf66678 83cd558b
<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;
} }
......
...@@ -136,7 +136,8 @@ class NotificationService ...@@ -136,7 +136,8 @@ class NotificationService
# * project team members with notification level higher then Participating # * project team members with notification level higher then Participating
# * watchers of the mr's labels # * watchers of the mr's labels
# * users with custom level checked with "new merge request" # * users with custom level checked with "new merge request"
# * approvers of the merge request #
# In EE, approvers of the merge request are also included
# #
# In EE, approvers of the merge request are also included # In EE, approvers of the merge request are also included
# #
...@@ -217,14 +218,6 @@ class NotificationService ...@@ -217,14 +218,6 @@ class NotificationService
reopen_resource_email(issue, current_user, :issue_status_changed_email, 'reopened') reopen_resource_email(issue, current_user, :issue_status_changed_email, 'reopened')
end end
# When we add approvers to a merge request we should send an email to:
#
# * the new approvers
#
def add_merge_request_approvers(merge_request, new_approvers, current_user)
add_mr_approvers_email(merge_request, new_approvers, current_user)
end
def merge_mr(merge_request, current_user) def merge_mr(merge_request, current_user)
close_resource_email( close_resource_email(
merge_request, merge_request,
...@@ -243,14 +236,6 @@ class NotificationService ...@@ -243,14 +236,6 @@ class NotificationService
) )
end end
def approve_mr(merge_request, current_user)
approve_mr_email(merge_request, merge_request.target_project, current_user)
end
def unapprove_mr(merge_request, current_user)
unapprove_mr_email(merge_request, merge_request.target_project, current_user)
end
def resolve_all_discussions(merge_request, current_user) def resolve_all_discussions(merge_request, current_user)
recipients = NotificationRecipientService.build_recipients( recipients = NotificationRecipientService.build_recipients(
merge_request, merge_request,
...@@ -527,30 +512,6 @@ class NotificationService ...@@ -527,30 +512,6 @@ class NotificationService
end end
end end
def approve_mr_email(merge_request, project, current_user)
recipients = NotificationRecipientService.build_recipients(merge_request, current_user, action: 'approve')
recipients.each do |recipient|
mailer.approved_merge_request_email(recipient.user.id, merge_request.id, current_user.id).deliver_later
end
end
def unapprove_mr_email(merge_request, project, current_user)
recipients = NotificationRecipientService.build_recipients(merge_request, current_user, action: 'unapprove')
recipients.each do |recipient|
mailer.unapproved_merge_request_email(recipient.user.id, merge_request.id, current_user.id).deliver_later
end
end
def add_mr_approvers_email(merge_request, approvers, current_user)
approvers.each do |approver|
recipient = approver.user
mailer.add_merge_request_approver_email(recipient.id, merge_request.id, current_user.id).deliver_later
end
end
def mailer def mailer
Notify Notify
end end
......
...@@ -527,111 +527,6 @@ module SystemNoteService ...@@ -527,111 +527,6 @@ module SystemNoteService
create_note(NoteSummary.new(noteable, project, author, body, action: 'moved')) create_note(NoteSummary.new(noteable, project, author, body, action: 'moved'))
end end
#
# noteable - Noteable object
# noteable_ref - Referenced noteable object
# user - User performing reference
#
# Example Note text:
#
# "marked this issue as related to gitlab-ce#9001"
#
# Returns the created Note object
def relate_issue(noteable, noteable_ref, user)
body = "marked this issue as related to #{noteable_ref.to_reference(noteable.project)}"
create_note(NoteSummary.new(noteable, noteable.project, user, body, action: 'relate'))
end
#
# noteable - Noteable object
# noteable_ref - Referenced noteable object
# user - User performing reference
#
# Example Note text:
#
# "removed the relation with gitlab-ce#9001"
#
# Returns the created Note object
def unrelate_issue(noteable, noteable_ref, user)
body = "removed the relation with #{noteable_ref.to_reference(noteable.project)}"
create_note(NoteSummary.new(noteable, noteable.project, user, body, action: 'unrelate'))
end
def epic_issue(epic, issue, user, type)
return unless validate_epic_issue_action_type(type)
action = type == :added ? 'epic_issue_added' : 'epic_issue_removed'
body = "#{type} issue #{issue.to_reference(epic.group)}"
create_note(NoteSummary.new(epic, nil, user, body, action: action))
end
def epic_issue_moved(from_epic, issue, to_epic, user)
epic_issue_moved_act(from_epic, issue, to_epic, user, verb: 'added', direction: 'from')
epic_issue_moved_act(to_epic, issue, from_epic, user, verb: 'moved', direction: 'to')
end
def epic_issue_moved_act(subject_epic, issue, object_epic, user, verb:, direction:)
action = 'epic_issue_moved'
body = "#{verb} issue #{issue.to_reference(subject_epic.group)} #{direction}" \
" epic #{subject_epic.to_reference(object_epic.group)}"
create_note(NoteSummary.new(object_epic, nil, user, body, action: action))
end
def issue_on_epic(issue, epic, user, type)
return unless validate_epic_issue_action_type(type)
if type == :added
direction = 'to'
action = 'issue_added_to_epic'
else
direction = 'from'
action = 'issue_removed_from_epic'
end
body = "#{type} #{direction} epic #{epic.to_reference(issue.project)}"
create_note(NoteSummary.new(issue, issue.project, user, body, action: action))
end
def issue_epic_change(issue, epic, user)
body = "changed epic to #{epic.to_reference(issue.project)}"
action = 'issue_changed_epic'
create_note(NoteSummary.new(issue, issue.project, user, body, action: action))
end
def validate_epic_issue_action_type(type)
[:added, :removed].include?(type)
end
# Called when the merge request is approved by user
#
# noteable - Noteable object
# user - User performing approve
#
# Example Note text:
#
# "approved this merge request"
#
# Returns the created Note object
def approve_mr(noteable, user)
body = "approved this merge request"
create_note(NoteSummary.new(noteable, noteable.project, user, body, action: 'approved'))
end
def unapprove_mr(noteable, user)
body = "unapproved this merge request"
create_note(NoteSummary.new(noteable, noteable.project, user, body, action: 'unapproved'))
end
# Called when a Noteable has been marked as a duplicate of another Issue # Called when a Noteable has been marked as a duplicate of another Issue
# #
# noteable - Noteable object # noteable - Noteable object
......
...@@ -108,14 +108,6 @@ class TodoService ...@@ -108,14 +108,6 @@ class TodoService
end end
end end
# When new approvers are added for a merge request:
#
# * create a todo for those users to approve the MR
#
def add_merge_request_approvers(merge_request, approvers)
create_approval_required_todos(merge_request, approvers, merge_request.author)
end
# When a new commit is pushed to a merge request we should: # When a new commit is pushed to a merge request we should:
# #
# * mark all pending todos related to the merge request for that user as done # * mark all pending todos related to the merge request for that user as done
...@@ -237,11 +229,6 @@ class TodoService ...@@ -237,11 +229,6 @@ class TodoService
def new_issuable(issuable, author) def new_issuable(issuable, author)
create_assignment_todo(issuable, author) create_assignment_todo(issuable, author)
if issuable.is_a?(MergeRequest)
create_approval_required_todos(issuable, issuable.overall_approvers, author)
end
create_mention_todos(issuable.project, issuable, author) create_mention_todos(issuable.project, issuable, author)
end end
...@@ -287,11 +274,6 @@ class TodoService ...@@ -287,11 +274,6 @@ class TodoService
create_todos(mentioned_users, attributes) create_todos(mentioned_users, attributes)
end end
def create_approval_required_todos(merge_request, approvers, author)
attributes = attributes_for_todo(merge_request.project, merge_request, author, Todo::APPROVAL_REQUIRED)
create_todos(approvers.map(&:user), attributes)
end
def create_build_failed_todo(merge_request, todo_author) def create_build_failed_todo(merge_request, todo_author)
attributes = attributes_for_todo(merge_request.project, merge_request, todo_author, Todo::BUILD_FAILED) attributes = attributes_for_todo(merge_request.project, merge_request, todo_author, Todo::BUILD_FAILED)
create_todos(todo_author, attributes) create_todos(todo_author, attributes)
......
...@@ -40,7 +40,7 @@ ...@@ -40,7 +40,7 @@
= project.human_import_status_name = project.human_import_status_name
- @repos.each do |repo| - @repos.each do |repo|
%tr{ id: "repo_#{repo.id}" } %tr{ id: "repo_#{repo.id}", data: { qa: { repo_path: repo.full_name } } }
%td %td
= provider_project_link(provider, repo.full_name) = provider_project_link(provider, repo.full_name)
%td.import-target %td.import-target
...@@ -50,7 +50,7 @@ ...@@ -50,7 +50,7 @@
- if current_user.can_select_namespace? - if current_user.can_select_namespace?
- selected = params[:namespace_id] || :current_user - selected = params[:namespace_id] || :current_user
- opts = current_user.can_create_group? ? { extra_group: Group.new(name: repo.owner.login, path: repo.owner.login) } : {} - opts = current_user.can_create_group? ? { extra_group: Group.new(name: repo.owner.login, path: repo.owner.login) } : {}
= select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'input-group-text select2 js-select-namespace', tabindex: 1 } = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'input-group-text select2 js-select-namespace qa-project-namespace-select', tabindex: 1 }
- else - else
= text_field_tag :path, current_user.namespace_path, class: "input-group-text input-large form-control", tabindex: 1, disabled: true = text_field_tag :path, current_user.namespace_path, class: "input-group-text input-large form-control", tabindex: 1, disabled: true
%span.input-group-prepend %span.input-group-prepend
......
...@@ -37,7 +37,7 @@ ...@@ -37,7 +37,7 @@
%li.dropdown-bold-header GitLab %li.dropdown-bold-header GitLab
- if current_user.can_create_project? - if current_user.can_create_project?
%li %li
= link_to 'New project', new_project_path = link_to 'New project', new_project_path, class: 'qa-global-new-project-link'
- if current_user.can_create_group? - if current_user.can_create_group?
%li %li
= link_to 'New group', new_group_path = link_to 'New group', new_group_path
......
%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 } }
...@@ -46,7 +46,6 @@ ...@@ -46,7 +46,6 @@
- mail_scheduler:mail_scheduler_issue_due - mail_scheduler:mail_scheduler_issue_due
- mail_scheduler:mail_scheduler_notification_service - mail_scheduler:mail_scheduler_notification_service
- object_storage_upload
- object_storage:object_storage_background_move - object_storage:object_storage_background_move
- object_storage:object_storage_migrate_uploads - object_storage:object_storage_migrate_uploads
...@@ -167,10 +166,6 @@ ...@@ -167,10 +166,6 @@
- geo:geo_repository_verification_secondary_single - geo:geo_repository_verification_secondary_single
- geo:geo_truncate_event_log - geo:geo_truncate_event_log
- object_storage_upload
- object_storage:object_storage_background_move
- object_storage:object_storage_migrate_uploads
- admin_emails - admin_emails
- create_github_webhook - create_github_webhook
- elastic_batch_project_indexer - elastic_batch_project_indexer
......
# frozen_string_literal: true
# @Deprecated - remove once the `object_storage_upload` queue is empty
# The queue has been renamed `object_storage:object_storage_background_upload`
#
class ObjectStorageUploadWorker
include ApplicationWorker
sidekiq_options retry: 5
def perform(uploader_class_name, subject_class_name, file_field, subject_id)
uploader_class = uploader_class_name.constantize
subject_class = subject_class_name.constantize
return unless uploader_class < ObjectStorage::Concern
return unless uploader_class.object_store_enabled?
return unless uploader_class.background_upload_enabled?
subject = subject_class.find(subject_id)
uploader = subject.public_send(file_field) # rubocop:disable GitlabSecurity/PublicSend
uploader.migrate!(ObjectStorage::Store::REMOTE)
end
end
---
title: Add dropdown to Groups link in top bar
merge_request: 18280
author:
type: added
---
title: Remove deprecated object_storage_upload queue.
merge_request:
author:
type: removed
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20180702120647) do ActiveRecord::Schema.define(version: 20180702181530) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
...@@ -2048,6 +2048,10 @@ ActiveRecord::Schema.define(version: 20180702120647) do ...@@ -2048,6 +2048,10 @@ ActiveRecord::Schema.define(version: 20180702120647) do
t.binary "wiki_verification_checksum" t.binary "wiki_verification_checksum"
t.string "last_repository_verification_failure" t.string "last_repository_verification_failure"
t.string "last_wiki_verification_failure" t.string "last_wiki_verification_failure"
t.datetime_with_timezone "repository_retry_at"
t.datetime_with_timezone "wiki_retry_at"
t.integer "repository_retry_count"
t.integer "wiki_retry_count"
end end
add_index "project_repository_states", ["last_repository_verification_failure"], name: "idx_repository_states_on_repository_failure_partial", where: "(last_repository_verification_failure IS NOT NULL)", using: :btree add_index "project_repository_states", ["last_repository_verification_failure"], name: "idx_repository_states_on_repository_failure_partial", where: "(last_repository_verification_failure IS NOT NULL)", using: :btree
......
...@@ -4,6 +4,24 @@ module Geo ...@@ -4,6 +4,24 @@ module Geo
@shard_name = shard_name @shard_name = shard_name
end end
def find_failed_repositories(batch_size:)
query = build_query_to_find_failed_projects(type: :repository, batch_size: batch_size)
cte = Gitlab::SQL::CTE.new(:failed_repositories, query)
Project.with(cte.to_arel)
.from(cte.alias_to(projects_table))
.order("projects.repository_retry_at ASC")
end
def find_failed_wikis(batch_size:)
query = build_query_to_find_failed_projects(type: :wiki, batch_size: batch_size)
cte = Gitlab::SQL::CTE.new(:failed_wikis, query)
Project.with(cte.to_arel)
.from(cte.alias_to(projects_table))
.order("projects.wiki_retry_at ASC")
end
def find_outdated_projects(batch_size:) def find_outdated_projects(batch_size:)
query = build_query_to_find_outdated_projects(batch_size: batch_size) query = build_query_to_find_outdated_projects(batch_size: batch_size)
cte = Gitlab::SQL::CTE.new(:outdated_projects, query) cte = Gitlab::SQL::CTE.new(:outdated_projects, query)
...@@ -45,6 +63,18 @@ module Geo ...@@ -45,6 +63,18 @@ module Geo
attr_reader :shard_name attr_reader :shard_name
def build_query_to_find_failed_projects(type:, batch_size:)
query =
projects_table
.join(repository_state_table).on(project_id_matcher)
.project(projects_table[:id], repository_state_table["#{type}_retry_at"])
.where(repository_state_table["last_#{type}_verification_failure"].not_eq(nil))
.take(batch_size)
query = apply_shard_restriction(query) if shard_name.present?
query
end
def build_query_to_find_outdated_projects(batch_size:) def build_query_to_find_outdated_projects(batch_size:)
query = query =
projects_table projects_table
......
...@@ -2,25 +2,28 @@ require 'ee/gitlab/service_desk' ...@@ -2,25 +2,28 @@ require 'ee/gitlab/service_desk'
module EE module EE
module NotificationService module NotificationService
# override extend ::Gitlab::Utils::Override
def send_new_note_notifications(note)
super
send_service_desk_notification(note)
end
def send_service_desk_notification(note) # When we add approvers to a merge request we should send an email to:
return unless EE::Gitlab::ServiceDesk.enabled? #
return unless note.noteable_type == 'Issue' # * the new approvers
#
def add_merge_request_approvers(merge_request, new_approvers, current_user)
add_mr_approvers_email(merge_request, new_approvers, current_user)
end
issue = note.noteable def approve_mr(merge_request, current_user)
support_bot = ::User.support_bot approve_mr_email(merge_request, merge_request.target_project, current_user)
end
return unless issue.service_desk_reply_to.present? def unapprove_mr(merge_request, current_user)
return unless issue.project.service_desk_enabled? unapprove_mr_email(merge_request, merge_request.target_project, current_user)
return if note.author == support_bot end
return unless issue.subscribed?(support_bot, issue.project)
mailer.service_desk_new_note_email(issue.id, note.id).deliver_later override :send_new_note_notifications
def send_new_note_notifications(note)
super
send_service_desk_notification(note)
end end
def mirror_was_hard_failed(project) def mirror_was_hard_failed(project)
...@@ -38,5 +41,46 @@ module EE ...@@ -38,5 +41,46 @@ module EE
def project_mirror_user_changed(new_mirror_user, deleted_user_name, project) def project_mirror_user_changed(new_mirror_user, deleted_user_name, project)
mailer.project_mirror_user_changed_email(new_mirror_user.id, deleted_user_name, project.id).deliver_later mailer.project_mirror_user_changed_email(new_mirror_user.id, deleted_user_name, project.id).deliver_later
end end
private
def add_mr_approvers_email(merge_request, approvers, current_user)
approvers.each do |approver|
recipient = approver.user
mailer.add_merge_request_approver_email(recipient.id, merge_request.id, current_user.id).deliver_later
end
end
def approve_mr_email(merge_request, project, current_user)
recipients = NotificationRecipientService.build_recipients(merge_request, current_user, action: 'approve')
recipients.each do |recipient|
mailer.approved_merge_request_email(recipient.user.id, merge_request.id, current_user.id).deliver_later
end
end
def unapprove_mr_email(merge_request, project, current_user)
recipients = NotificationRecipientService.build_recipients(merge_request, current_user, action: 'unapprove')
recipients.each do |recipient|
mailer.unapproved_merge_request_email(recipient.user.id, merge_request.id, current_user.id).deliver_later
end
end
def send_service_desk_notification(note)
return unless EE::Gitlab::ServiceDesk.enabled?
return unless note.noteable_type == 'Issue'
issue = note.noteable
support_bot = ::User.support_bot
return unless issue.service_desk_reply_to.present?
return unless issue.project.service_desk_enabled?
return if note.author == support_bot
return unless issue.subscribed?(support_bot, issue.project)
mailer.service_desk_new_note_email(issue.id, note.id).deliver_later
end
end end
end end
...@@ -6,6 +6,111 @@ module EE ...@@ -6,6 +6,111 @@ module EE
module SystemNoteService module SystemNoteService
extend self extend self
#
# noteable - Noteable object
# noteable_ref - Referenced noteable object
# user - User performing reference
#
# Example Note text:
#
# "marked this issue as related to gitlab-ce#9001"
#
# Returns the created Note object
def relate_issue(noteable, noteable_ref, user)
body = "marked this issue as related to #{noteable_ref.to_reference(noteable.project)}"
create_note(NoteSummary.new(noteable, noteable.project, user, body, action: 'relate'))
end
#
# noteable - Noteable object
# noteable_ref - Referenced noteable object
# user - User performing reference
#
# Example Note text:
#
# "removed the relation with gitlab-ce#9001"
#
# Returns the created Note object
def unrelate_issue(noteable, noteable_ref, user)
body = "removed the relation with #{noteable_ref.to_reference(noteable.project)}"
create_note(NoteSummary.new(noteable, noteable.project, user, body, action: 'unrelate'))
end
def epic_issue(epic, issue, user, type)
return unless validate_epic_issue_action_type(type)
action = type == :added ? 'epic_issue_added' : 'epic_issue_removed'
body = "#{type} issue #{issue.to_reference(epic.group)}"
create_note(NoteSummary.new(epic, nil, user, body, action: action))
end
def epic_issue_moved(from_epic, issue, to_epic, user)
epic_issue_moved_act(from_epic, issue, to_epic, user, verb: 'added', direction: 'from')
epic_issue_moved_act(to_epic, issue, from_epic, user, verb: 'moved', direction: 'to')
end
def epic_issue_moved_act(subject_epic, issue, object_epic, user, verb:, direction:)
action = 'epic_issue_moved'
body = "#{verb} issue #{issue.to_reference(subject_epic.group)} #{direction}" \
" epic #{subject_epic.to_reference(object_epic.group)}"
create_note(NoteSummary.new(object_epic, nil, user, body, action: action))
end
def issue_on_epic(issue, epic, user, type)
return unless validate_epic_issue_action_type(type)
if type == :added
direction = 'to'
action = 'issue_added_to_epic'
else
direction = 'from'
action = 'issue_removed_from_epic'
end
body = "#{type} #{direction} epic #{epic.to_reference(issue.project)}"
create_note(NoteSummary.new(issue, issue.project, user, body, action: action))
end
def issue_epic_change(issue, epic, user)
body = "changed epic to #{epic.to_reference(issue.project)}"
action = 'issue_changed_epic'
create_note(NoteSummary.new(issue, issue.project, user, body, action: action))
end
def validate_epic_issue_action_type(type)
[:added, :removed].include?(type)
end
# Called when the merge request is approved by user
#
# noteable - Noteable object
# user - User performing approve
#
# Example Note text:
#
# "approved this merge request"
#
# Returns the created Note object
def approve_mr(noteable, user)
body = "approved this merge request"
create_note(NoteSummary.new(noteable, noteable.project, user, body, action: 'approved'))
end
def unapprove_mr(noteable, user)
body = "unapproved this merge request"
create_note(NoteSummary.new(noteable, noteable.project, user, body, action: 'unapproved'))
end
# Called when the weight of a Noteable is changed # Called when the weight of a Noteable is changed
# #
# noteable - Noteable object # noteable - Noteable object
...@@ -19,7 +124,6 @@ module EE ...@@ -19,7 +124,6 @@ module EE
# "changed weight to 4" # "changed weight to 4"
# #
# Returns the created Note object # Returns the created Note object
def change_weight_note(noteable, project, author) def change_weight_note(noteable, project, author)
body = noteable.weight ? "changed weight to **#{noteable.weight}**" : 'removed the weight' body = noteable.weight ? "changed weight to **#{noteable.weight}**" : 'removed the weight'
create_note(NoteSummary.new(noteable, project, author, body, action: 'weight')) create_note(NoteSummary.new(noteable, project, author, body, action: 'weight'))
......
...@@ -2,6 +2,23 @@ module EE ...@@ -2,6 +2,23 @@ module EE
module TodoService module TodoService
extend ::Gitlab::Utils::Override extend ::Gitlab::Utils::Override
# When new approvers are added for a merge request:
#
# * create a todo for those users to approve the MR
#
def add_merge_request_approvers(merge_request, approvers)
create_approval_required_todos(merge_request, approvers, merge_request.author)
end
override :new_issuable
def new_issuable(issuable, author)
if issuable.is_a?(MergeRequest)
create_approval_required_todos(issuable, issuable.overall_approvers, author)
end
super
end
def new_epic(epic, current_user) def new_epic(epic, current_user)
create_mention_todos(nil, epic, current_user) create_mention_todos(nil, epic, current_user)
end end
...@@ -10,6 +27,8 @@ module EE ...@@ -10,6 +27,8 @@ module EE
create_mention_todos(nil, epic, current_user, nil, skip_users) create_mention_todos(nil, epic, current_user, nil, skip_users)
end end
private
override :attributes_for_target override :attributes_for_target
def attributes_for_target(target) def attributes_for_target(target)
attributes = super attributes = super
...@@ -20,5 +39,10 @@ module EE ...@@ -20,5 +39,10 @@ module EE
attributes attributes
end end
def create_approval_required_todos(merge_request, approvers, author)
attributes = attributes_for_todo(merge_request.project, merge_request, author, ::Todo::APPROVAL_REQUIRED)
create_todos(approvers.map(&:user), attributes)
end
end end
end end
...@@ -36,18 +36,35 @@ module Geo ...@@ -36,18 +36,35 @@ module Geo
def reset_repository_checksum! def reset_repository_checksum!
return if repository_state.nil? return if repository_state.nil?
repository_state.update!("#{repository_checksum_column}" => nil, "#{repository_failure_column}" => nil) repository_state.update!(
"#{repository_checksum_column}" => nil,
"#{repository_failure_column}" => nil,
"#{repository_retry_at_column}" => nil,
"#{repository_retry_count_column}" => nil
)
rescue => e rescue => e
log_error('Cannot reset repository checksum', e) log_error('Cannot reset repository checksum', e)
raise RepositoryUpdateError, "Cannot reset repository checksum: #{e}" raise RepositoryUpdateError, "Cannot reset repository checksum: #{e}"
end end
def repository_checksum_column def repository_checksum_column
"#{Geo::RepositoryUpdatedEvent.sources.key(source)}_verification_checksum" "#{repository_type}_verification_checksum"
end end
def repository_failure_column def repository_failure_column
"last_#{Geo::RepositoryUpdatedEvent.sources.key(source)}_verification_failure" "last_#{repository_type}_verification_failure"
end
def repository_retry_at_column
"#{repository_type}_retry_at"
end
def repository_retry_count_column
"#{repository_type}_retry_count"
end
def repository_type
@repository_type ||= Geo::RepositoryUpdatedEvent.sources.key(source)
end end
end end
end end
module Geo module Geo
class RepositoryVerificationPrimaryService class RepositoryVerificationPrimaryService
include Delay
include Gitlab::Geo::ProjectLogHelpers include Gitlab::Geo::ProjectLogHelpers
def initialize(project) def initialize(project)
...@@ -33,12 +34,29 @@ module Geo ...@@ -33,12 +34,29 @@ module Geo
end end
def update_repository_state!(type, checksum: nil, failure: nil) def update_repository_state!(type, checksum: nil, failure: nil)
retry_at, retry_count =
if failure.present?
retry_count = repository_state.public_send("#{type}_retry_count").to_i + 1 # rubocop:disable GitlabSecurity/PublicSend
[next_retry_time(retry_count), retry_count]
end
repository_state.update!( repository_state.update!(
"#{type}_verification_checksum" => checksum, "#{type}_verification_checksum" => checksum,
"last_#{type}_verification_failure" => failure "last_#{type}_verification_failure" => failure,
"#{type}_retry_at" => retry_at,
"#{type}_retry_count" => retry_count
) )
end end
# To prevent the retry time from storing invalid dates in the database,
# cap the max time to a week plus some random jitter value.
def next_retry_time(retry_count)
proposed_time = Time.now + delay(retry_count).seconds
max_future_time = Time.now + 7.days + delay(1).seconds
[proposed_time, max_future_time].min
end
def repository_state def repository_state
@repository_state ||= project.repository_state || project.build_repository_state @repository_state ||= project.repository_state || project.build_repository_state
end end
......
...@@ -42,12 +42,13 @@ module Geo ...@@ -42,12 +42,13 @@ module Geo
def load_pending_resources def load_pending_resources
resources = find_unverified_project_ids(batch_size: db_retrieve_batch_size) resources = find_unverified_project_ids(batch_size: db_retrieve_batch_size)
remaining_capacity = db_retrieve_batch_size - resources.size remaining_capacity = db_retrieve_batch_size - resources.size
return resources if remaining_capacity.zero?
if remaining_capacity.zero? resources += find_outdated_project_ids(batch_size: remaining_capacity)
resources remaining_capacity = db_retrieve_batch_size - resources.size
else return resources if remaining_capacity.zero?
resources + find_outdated_project_ids(batch_size: remaining_capacity)
end resources + find_failed_project_ids(batch_size: remaining_capacity)
end end
def find_unverified_project_ids(batch_size:) def find_unverified_project_ids(batch_size:)
...@@ -57,6 +58,21 @@ module Geo ...@@ -57,6 +58,21 @@ module Geo
def find_outdated_project_ids(batch_size:) def find_outdated_project_ids(batch_size:)
finder.find_outdated_projects(batch_size: batch_size).pluck(:id) finder.find_outdated_projects(batch_size: batch_size).pluck(:id)
end end
def find_failed_project_ids(batch_size:)
repositories_ids = find_failed_repositories_ids(batch_size: batch_size)
wiki_ids = find_failed_wiki_ids(batch_size: batch_size)
take_batch(repositories_ids, wiki_ids, batch_size: batch_size)
end
def find_failed_repositories_ids(batch_size:)
finder.find_failed_repositories(batch_size: batch_size).pluck(:id)
end
def find_failed_wiki_ids(batch_size:)
finder.find_failed_wikis(batch_size: batch_size).pluck(:id)
end
end end
end end
end end
......
---
title: Geo - Retry checksum calculation for failures on the primary node
merge_request: 6295
author:
type: changed
class AddRetryFieldsToProjectRepositoryStates < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
add_column :project_repository_states, :repository_retry_at, :datetime_with_timezone
add_column :project_repository_states, :wiki_retry_at, :datetime_with_timezone
add_column :project_repository_states, :repository_retry_count, :integer
add_column :project_repository_states, :wiki_retry_count, :integer
end
end
...@@ -71,7 +71,6 @@ describe 'Admin::AuditLogs', :js do ...@@ -71,7 +71,6 @@ describe 'Admin::AuditLogs', :js do
it 'filters by group' do it 'filters by group' do
filter_by_type('Group Events') filter_by_type('Group Events')
click_button 'Group'
find('.group-item-select').click find('.group-item-select').click
wait_for_requests wait_for_requests
find('.select2-results').click find('.select2-results').click
...@@ -95,7 +94,6 @@ describe 'Admin::AuditLogs', :js do ...@@ -95,7 +94,6 @@ describe 'Admin::AuditLogs', :js do
it 'filters by project' do it 'filters by project' do
filter_by_type('Project Events') filter_by_type('Project Events')
click_button 'Project'
find('.project-item-select').click find('.project-item-select').click
wait_for_requests wait_for_requests
find('.select2-results').click find('.select2-results').click
......
...@@ -14,8 +14,8 @@ describe 'The group dashboard' do ...@@ -14,8 +14,8 @@ describe 'The group dashboard' do
visit dashboard_groups_path visit dashboard_groups_path
within('.navbar') do within('.navbar') do
expect(page).to have_link('Projects') expect(page).to have_button('Projects')
expect(page).to have_link('Groups') expect(page).to have_button('Groups')
expect(page).to have_link('Activity') expect(page).to have_link('Activity')
expect(page).to have_link('Milestones') expect(page).to have_link('Milestones')
expect(page).to have_link('Snippets') expect(page).to have_link('Snippets')
...@@ -27,8 +27,8 @@ describe 'The group dashboard' do ...@@ -27,8 +27,8 @@ describe 'The group dashboard' do
visit dashboard_groups_path visit dashboard_groups_path
within('.navbar') do within('.navbar') do
expect(page).to have_link('Projects') expect(page).to have_button('Projects')
expect(page).to have_link('Groups') expect(page).to have_button('Groups')
expect(page).not_to have_link('Activity') expect(page).not_to have_link('Activity')
expect(page).not_to have_link('Milestones') expect(page).not_to have_link('Milestones')
expect(page).to have_link('Snippets') expect(page).to have_link('Snippets')
......
...@@ -3,6 +3,92 @@ require 'spec_helper' ...@@ -3,6 +3,92 @@ require 'spec_helper'
describe Geo::RepositoryVerificationFinder, :postgresql do describe Geo::RepositoryVerificationFinder, :postgresql do
set(:project) { create(:project) } set(:project) { create(:project) }
describe '#find_failed_repositories' do
it 'returns projects where repository verification failed' do
create(:repository_state, :repository_failed, :wiki_verified, project: project)
expect(subject.find_failed_repositories(batch_size: 10))
.to match_array(project)
end
it 'does not return projects where repository verification is outdated' do
create(:repository_state, :repository_outdated, project: project)
expect(subject.find_failed_repositories(batch_size: 10)).to be_empty
end
it 'does not return projects where repository verification is pending' do
create(:repository_state, :wiki_verified, project: project)
expect(subject.find_failed_repositories(batch_size: 10)).to be_empty
end
it 'returns projects ordered by next retry time' do
next_project = create(:project)
create(:repository_state, :repository_failed, repository_retry_at: 1.hour.from_now, project: project)
create(:repository_state, :repository_failed, repository_retry_at: 30.minutes.from_now, project: next_project)
expect(subject.find_failed_repositories(batch_size: 10)).to eq [next_project, project]
end
context 'with shard restriction' do
subject { described_class.new(shard_name: project.repository_storage) }
it 'does not return projects on other shards' do
project_other_shard = create(:project)
project_other_shard.update_column(:repository_storage, 'other')
create(:repository_state, :repository_failed, project: project)
create(:repository_state, :repository_failed, project: project_other_shard)
expect(subject.find_failed_repositories(batch_size: 10))
.to match_array(project)
end
end
end
describe '#find_failed_wikis' do
it 'returns projects where wiki verification failed' do
create(:repository_state, :repository_verified, :wiki_failed, project: project)
expect(subject.find_failed_wikis(batch_size: 10))
.to match_array(project)
end
it 'does not return projects where wiki verification is outdated' do
create(:repository_state, :wiki_outdated, project: project)
expect(subject.find_failed_wikis(batch_size: 10)).to be_empty
end
it 'does not return projects where wiki verification is pending' do
create(:repository_state, :repository_verified, project: project)
expect(subject.find_failed_wikis(batch_size: 10)).to be_empty
end
it 'returns projects ordered by next retry time' do
next_project = create(:project)
create(:repository_state, :wiki_failed, wiki_retry_at: 1.hour.from_now, project: project)
create(:repository_state, :wiki_failed, wiki_retry_at: 30.minutes.from_now, project: next_project)
expect(subject.find_failed_wikis(batch_size: 10)).to eq [next_project, project]
end
context 'with shard restriction' do
subject { described_class.new(shard_name: project.repository_storage) }
it 'does not return projects on other shards' do
project_other_shard = create(:project)
project_other_shard.update_column(:repository_storage, 'other')
create(:repository_state, :wiki_failed, project: project)
create(:repository_state, :wiki_failed, project: project_other_shard)
expect(subject.find_failed_wikis(batch_size: 10))
.to match_array(project)
end
end
end
describe '#find_outdated_projects' do describe '#find_outdated_projects' do
it 'returns projects where repository verification is outdated' do it 'returns projects where repository verification is outdated' do
create(:repository_state, :repository_outdated, project: project) create(:repository_state, :repository_outdated, project: project)
......
...@@ -62,7 +62,7 @@ describe EE::NotificationService, :mailer do ...@@ -62,7 +62,7 @@ describe EE::NotificationService, :mailer do
end end
def execute! def execute!
subject.send_service_desk_notification(note) subject.new_note(note)
end end
def self.it_should_email! def self.it_should_email!
...@@ -105,7 +105,7 @@ describe EE::NotificationService, :mailer do ...@@ -105,7 +105,7 @@ describe EE::NotificationService, :mailer do
context 'when the license doesn\'t allow service desk' do context 'when the license doesn\'t allow service desk' do
before do before do
expect(EE::Gitlab::ServiceDesk).to receive(:enabled?).and_return(false) allow(::EE::Gitlab::ServiceDesk).to receive(:enabled?).and_return(false)
end end
it_should_not_email! it_should_not_email!
......
...@@ -48,13 +48,23 @@ describe Geo::RepositoryUpdatedService do ...@@ -48,13 +48,23 @@ describe Geo::RepositoryUpdatedService do
expect { subject.execute }.to change { repository_state.reload.public_send("last_#{method_prefix}_verification_failure") }.to(nil) expect { subject.execute }.to change { repository_state.reload.public_send("last_#{method_prefix}_verification_failure") }.to(nil)
end end
it 'resets the retry_at column' do
repository_state.update!("#{method_prefix}_retry_at" => 1.hour.from_now)
expect { subject.execute }.to change { repository_state.reload.public_send("#{method_prefix}_retry_at") }.to(nil)
end
it 'resets the retry_count column' do
repository_state.update!("#{method_prefix}_retry_count" => 1)
expect { subject.execute }.to change { repository_state.reload.public_send("#{method_prefix}_retry_count") }.to(nil)
end
it 'does not raise an error when project have never been verified' do it 'does not raise an error when project have never been verified' do
expect { described_class.new(create(:project)) }.not_to raise_error expect { described_class.new(create(:project)) }.not_to raise_error
end end
it 'raises a Geo::RepositoryUpdatedService::RepositoryUpdateError when an error occurs' do it 'raises a Geo::RepositoryUpdatedService::RepositoryUpdateError when an error occurs' do
allow(subject.repository_state).to receive(:update!) allow(subject.repository_state).to receive(:update!)
.with("#{method_prefix}_verification_checksum" => nil, "last_#{method_prefix}_verification_failure" => nil) .with("#{method_prefix}_verification_checksum" => nil, "last_#{method_prefix}_verification_failure" => nil, "#{method_prefix}_retry_at" => nil, "#{method_prefix}_retry_count" => nil)
.and_raise(ActiveRecord::RecordInvalid.new(repository_state)) .and_raise(ActiveRecord::RecordInvalid.new(repository_state))
expect { subject.execute }.to raise_error Geo::RepositoryUpdatedService::RepositoryUpdateError, /Cannot reset repository checksum/ expect { subject.execute }.to raise_error Geo::RepositoryUpdatedService::RepositoryUpdateError, /Cannot reset repository checksum/
......
...@@ -18,7 +18,11 @@ describe Geo::RepositoryVerificationPrimaryService do ...@@ -18,7 +18,11 @@ describe Geo::RepositoryVerificationPrimaryService do
repository_verification_checksum: 'f123', repository_verification_checksum: 'f123',
last_repository_verification_failure: nil, last_repository_verification_failure: nil,
wiki_verification_checksum: 'e321', wiki_verification_checksum: 'e321',
last_wiki_verification_failure: nil last_wiki_verification_failure: nil,
repository_retry_at: nil,
repository_retry_count: nil,
wiki_retry_at: nil,
wiki_retry_count: nil
) )
end end
...@@ -38,7 +42,11 @@ describe Geo::RepositoryVerificationPrimaryService do ...@@ -38,7 +42,11 @@ describe Geo::RepositoryVerificationPrimaryService do
repository_verification_checksum: 'f123', repository_verification_checksum: 'f123',
last_repository_verification_failure: nil, last_repository_verification_failure: nil,
wiki_verification_checksum: 'e321', wiki_verification_checksum: 'e321',
last_wiki_verification_failure: nil last_wiki_verification_failure: nil,
repository_retry_at: nil,
repository_retry_count: nil,
wiki_retry_at: nil,
wiki_retry_count: nil
) )
end end
...@@ -58,7 +66,11 @@ describe Geo::RepositoryVerificationPrimaryService do ...@@ -58,7 +66,11 @@ describe Geo::RepositoryVerificationPrimaryService do
repository_verification_checksum: 'f123', repository_verification_checksum: 'f123',
last_repository_verification_failure: nil, last_repository_verification_failure: nil,
wiki_verification_checksum: 'e321', wiki_verification_checksum: 'e321',
last_wiki_verification_failure: nil last_wiki_verification_failure: nil,
repository_retry_at: nil,
repository_retry_count: nil,
wiki_retry_at: nil,
wiki_retry_count: nil
) )
end end
...@@ -89,7 +101,11 @@ describe Geo::RepositoryVerificationPrimaryService do ...@@ -89,7 +101,11 @@ describe Geo::RepositoryVerificationPrimaryService do
repository_verification_checksum: 'f123', repository_verification_checksum: 'f123',
last_repository_verification_failure: nil, last_repository_verification_failure: nil,
wiki_verification_checksum: 'e321', wiki_verification_checksum: 'e321',
last_wiki_verification_failure: nil last_wiki_verification_failure: nil,
repository_retry_at: nil,
repository_retry_count: nil,
wiki_retry_at: nil,
wiki_retry_count: nil
) )
end end
...@@ -100,7 +116,11 @@ describe Geo::RepositoryVerificationPrimaryService do ...@@ -100,7 +116,11 @@ describe Geo::RepositoryVerificationPrimaryService do
repository_verification_checksum: '0000000000000000000000000000000000000000', repository_verification_checksum: '0000000000000000000000000000000000000000',
last_repository_verification_failure: nil, last_repository_verification_failure: nil,
wiki_verification_checksum: '0000000000000000000000000000000000000000', wiki_verification_checksum: '0000000000000000000000000000000000000000',
last_wiki_verification_failure: nil last_wiki_verification_failure: nil,
repository_retry_at: nil,
repository_retry_count: nil,
wiki_retry_at: nil,
wiki_retry_count: nil
) )
end end
...@@ -114,25 +134,58 @@ describe Geo::RepositoryVerificationPrimaryService do ...@@ -114,25 +134,58 @@ describe Geo::RepositoryVerificationPrimaryService do
repository_verification_checksum: '0000000000000000000000000000000000000000', repository_verification_checksum: '0000000000000000000000000000000000000000',
last_repository_verification_failure: nil, last_repository_verification_failure: nil,
wiki_verification_checksum: '0000000000000000000000000000000000000000', wiki_verification_checksum: '0000000000000000000000000000000000000000',
last_wiki_verification_failure: nil last_wiki_verification_failure: nil,
repository_retry_at: nil,
repository_retry_count: nil,
wiki_retry_at: nil,
wiki_retry_count: nil
) )
end end
it 'keeps track of failures when calculating the repository checksum' do context 'when checksum calculation fails' do
stub_project_repository(project, repository) before do
stub_wiki_repository(project.wiki, wiki) stub_project_repository(project, repository)
stub_wiki_repository(project.wiki, wiki)
allow(repository).to receive(:checksum).and_raise('Something went wrong with repository') allow(repository).to receive(:checksum).and_raise('Something went wrong with repository')
allow(wiki).to receive(:checksum).twice.and_raise('Something went wrong with wiki') allow(wiki).to receive(:checksum).twice.and_raise('Something went wrong with wiki')
end
subject.execute it 'keeps track of failures' do
subject.execute
expect(project.repository_state).to have_attributes( expect(project.repository_state).to have_attributes(
repository_verification_checksum: nil, repository_verification_checksum: nil,
last_repository_verification_failure: 'Something went wrong with repository', last_repository_verification_failure: 'Something went wrong with repository',
wiki_verification_checksum: nil, wiki_verification_checksum: nil,
last_wiki_verification_failure: 'Something went wrong with wiki' last_wiki_verification_failure: 'Something went wrong with wiki',
) repository_retry_at: be_present,
repository_retry_count: 1,
wiki_retry_at: be_present,
wiki_retry_count: 1
)
end
it 'ensures the next retry time is capped properly' do
repository_state =
create(:repository_state,
project: project,
repository_retry_count: 30,
wiki_retry_count: 30)
subject.execute
expect(repository_state.reload).to have_attributes(
repository_verification_checksum: nil,
last_repository_verification_failure: 'Something went wrong with repository',
wiki_verification_checksum: nil,
last_wiki_verification_failure: 'Something went wrong with wiki',
repository_retry_at: be_within(100.seconds).of(Time.now + 7.days),
repository_retry_count: 31,
wiki_retry_at: be_within(100.seconds).of(Time.now + 7.days),
wiki_retry_count: 31
)
end
end end
end end
......
...@@ -63,6 +63,26 @@ describe Geo::RepositoryVerification::Primary::ShardWorker, :postgresql, :clean_ ...@@ -63,6 +63,26 @@ describe Geo::RepositoryVerification::Primary::ShardWorker, :postgresql, :clean_
subject.perform(shard_name) subject.perform(shard_name)
end end
it 'performs Geo::RepositoryVerification::Primary::SingleWorker for projects where repository verification failed' do
repository_verification_failed = create(:project)
create(:repository_state, :repository_failed, :wiki_verified, project: repository_verification_failed)
expect(primary_singleworker).to receive(:perform_async).with(repository_verification_failed.id)
subject.perform(shard_name)
end
it 'performs Geo::RepositoryVerification::Primary::SingleWorker for projects where wiki verification failed' do
wiki_verification_failed = create(:project)
create(:repository_state, :repository_verified, :wiki_failed, project: wiki_verification_failed)
expect(primary_singleworker).to receive(:perform_async).with(wiki_verification_failed.id)
subject.perform(shard_name)
end
it 'does not perform Geo::RepositoryVerification::Primary::SingleWorker when shard becomes unhealthy' do it 'does not perform Geo::RepositoryVerification::Primary::SingleWorker when shard becomes unhealthy' do
create(:project) create(:project)
...@@ -133,14 +153,14 @@ describe Geo::RepositoryVerification::Primary::ShardWorker, :postgresql, :clean_ ...@@ -133,14 +153,14 @@ describe Geo::RepositoryVerification::Primary::ShardWorker, :postgresql, :clean_
end end
end end
it 'handles multiple batches of projects needing verification, skipping failed repos' do it 'handles multiple batches of projects needing verification, including failed repos' do
expect(primary_singleworker).to receive(:perform_async).with(project_repo_unverified.id).once.and_call_original expect(primary_singleworker).to receive(:perform_async).with(project_repo_unverified.id).once.and_call_original
expect(primary_singleworker).to receive(:perform_async).with(project_wiki_unverified.id).once.and_call_original expect(primary_singleworker).to receive(:perform_async).with(project_wiki_unverified.id).once.and_call_original
expect(primary_singleworker).to receive(:perform_async).with(project_repo_verified.id).once.and_call_original expect(primary_singleworker).to receive(:perform_async).with(project_repo_verified.id).once.and_call_original
expect(primary_singleworker).to receive(:perform_async).with(project_wiki_verified.id).once.and_call_original expect(primary_singleworker).to receive(:perform_async).with(project_wiki_verified.id).once.and_call_original
expect(primary_singleworker).not_to receive(:perform_async).with(project_both_failed.id) expect(primary_singleworker).to receive(:perform_async).with(project_both_failed.id).once.and_call_original
expect(primary_singleworker).not_to receive(:perform_async).with(project_repo_failed_wiki_verified.id) expect(primary_singleworker).to receive(:perform_async).with(project_repo_failed_wiki_verified.id).once.and_call_original
expect(primary_singleworker).not_to receive(:perform_async).with(project_repo_verified_wiki_failed.id) expect(primary_singleworker).to receive(:perform_async).with(project_repo_verified_wiki_failed.id).once.and_call_original
8.times do 8.times do
Sidekiq::Testing.inline! { subject.perform(shard_name) } Sidekiq::Testing.inline! { subject.perform(shard_name) }
......
...@@ -40,6 +40,7 @@ module QA ...@@ -40,6 +40,7 @@ module QA
autoload :Issue, 'qa/factory/resource/issue' autoload :Issue, 'qa/factory/resource/issue'
autoload :Project, 'qa/factory/resource/project' autoload :Project, 'qa/factory/resource/project'
autoload :MergeRequest, 'qa/factory/resource/merge_request' autoload :MergeRequest, 'qa/factory/resource/merge_request'
autoload :ProjectImportedFromGithub, 'qa/factory/resource/project_imported_from_github'
autoload :DeployKey, 'qa/factory/resource/deploy_key' autoload :DeployKey, 'qa/factory/resource/deploy_key'
autoload :Branch, 'qa/factory/resource/branch' autoload :Branch, 'qa/factory/resource/branch'
autoload :SecretVariable, 'qa/factory/resource/secret_variable' autoload :SecretVariable, 'qa/factory/resource/secret_variable'
...@@ -79,6 +80,7 @@ module QA ...@@ -79,6 +80,7 @@ module QA
autoload :Instance, 'qa/scenario/test/instance' autoload :Instance, 'qa/scenario/test/instance'
module Integration module Integration
autoload :Github, 'qa/scenario/test/integration/github'
autoload :LDAP, 'qa/scenario/test/integration/ldap' autoload :LDAP, 'qa/scenario/test/integration/ldap'
autoload :Kubernetes, 'qa/scenario/test/integration/kubernetes' autoload :Kubernetes, 'qa/scenario/test/integration/kubernetes'
autoload :Mattermost, 'qa/scenario/test/integration/mattermost' autoload :Mattermost, 'qa/scenario/test/integration/mattermost'
...@@ -132,6 +134,10 @@ module QA ...@@ -132,6 +134,10 @@ module QA
autoload :Show, 'qa/page/project/show' autoload :Show, 'qa/page/project/show'
autoload :Activity, 'qa/page/project/activity' autoload :Activity, 'qa/page/project/activity'
module Import
autoload :Github, 'qa/page/project/import/github'
end
module Pipeline module Pipeline
autoload :Index, 'qa/page/project/pipeline/index' autoload :Index, 'qa/page/project/pipeline/index'
autoload :Show, 'qa/page/project/pipeline/show' autoload :Show, 'qa/page/project/pipeline/show'
...@@ -184,6 +190,10 @@ module QA ...@@ -184,6 +190,10 @@ module QA
autoload :PersonalAccessTokens, 'qa/page/profile/personal_access_tokens' autoload :PersonalAccessTokens, 'qa/page/profile/personal_access_tokens'
end end
module Issuable
autoload :Sidebar, 'qa/page/issuable/sidebar'
end
module MergeRequest module MergeRequest
autoload :New, 'qa/page/merge_request/new' autoload :New, 'qa/page/merge_request/new'
autoload :Show, 'qa/page/merge_request/show' autoload :Show, 'qa/page/merge_request/show'
...@@ -206,6 +216,7 @@ module QA ...@@ -206,6 +216,7 @@ module QA
# #
module Component module Component
autoload :Dropzone, 'qa/page/component/dropzone' autoload :Dropzone, 'qa/page/component/dropzone'
autoload :Select2, 'qa/page/component/select2'
end end
end end
......
...@@ -23,7 +23,7 @@ module QA ...@@ -23,7 +23,7 @@ module QA
Page::Group::New.perform do |group| Page::Group::New.perform do |group|
group.set_path(@path) group.set_path(@path)
group.set_description(@description) group.set_description(@description)
group.set_visibility('Private') group.set_visibility('Public')
group.create group.create
end end
end end
......
...@@ -5,16 +5,12 @@ module QA ...@@ -5,16 +5,12 @@ module QA
module Resource module Resource
class Project < Factory::Base class Project < Factory::Base
attr_writer :description attr_writer :description
attr_reader :name
dependency Factory::Resource::Group, as: :group dependency Factory::Resource::Group, as: :group
def name=(name) product :name do |factory|
@name = "#{name}-#{SecureRandom.hex(8)}" factory.name
@description = 'My awesome project'
end
product :name do
Page::Project::Show.act { project_name }
end end
product :repository_ssh_location do product :repository_ssh_location do
...@@ -24,6 +20,14 @@ module QA ...@@ -24,6 +20,14 @@ module QA
end end
end end
def initialize
@description = 'My awesome project'
end
def name=(raw_name)
@name = "#{raw_name}-#{SecureRandom.hex(8)}"
end
def fabricate! def fabricate!
group.visit! group.visit!
......
require 'securerandom'
module QA
module Factory
module Resource
class ProjectImportedFromGithub < Resource::Project
attr_writer :personal_access_token, :github_repository_path
dependency Factory::Resource::Group, as: :group
product :name do |factory|
factory.name
end
def fabricate!
group.visit!
Page::Group::Show.act { go_to_new_project }
Page::Project::New.perform do |page|
page.go_to_import_project
end
Page::Project::New.perform do |page|
page.go_to_github_import
end
Page::Project::Import::Github.perform do |page|
page.add_personal_access_token(@personal_access_token)
page.list_repos
page.import!(@github_repository_path, @name)
end
end
end
end
end
end
...@@ -21,8 +21,8 @@ module QA ...@@ -21,8 +21,8 @@ module QA
Page::Group::New.perform do |group| Page::Group::New.perform do |group|
group.set_path(@name) group.set_path(@name)
group.set_description('GitLab QA Sandbox') group.set_description('GitLab QA Sandbox Group')
group.set_visibility('Private') group.set_visibility('Public')
group.create group.create
end end
end end
......
module QA
module Page
module Component
module Select2
def select_item(item_text)
find('ul.select2-result-sub > li', text: item_text).click
end
end
end
end
end
module QA
module Page
module Issuable
class Sidebar < Page::Base
view 'app/views/shared/issuable/_sidebar.html.haml' do
element :labels_block, ".issuable-show-labels"
end
def has_label?(label)
page.within('.issuable-show-labels') do
!!find('span', text: label)
end
end
end
end
end
end
...@@ -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
......
...@@ -10,6 +10,8 @@ module QA ...@@ -10,6 +10,8 @@ module QA
element :operations_kubernetes_link, "title: _('Kubernetes')" element :operations_kubernetes_link, "title: _('Kubernetes')"
element :issues_link, /link_to.*shortcuts-issues/ element :issues_link, /link_to.*shortcuts-issues/
element :issues_link_text, "Issues" element :issues_link_text, "Issues"
element :merge_requests_link, /link_to.*shortcuts-merge_requests/
element :merge_requests_link_text, "Merge Requests"
element :top_level_items, '.sidebar-top-level-items' element :top_level_items, '.sidebar-top-level-items'
element :operations_section, "class: 'shortcuts-operations'" element :operations_section, "class: 'shortcuts-operations'"
element :activity_link, "title: 'Activity'" element :activity_link, "title: 'Activity'"
...@@ -62,6 +64,12 @@ module QA ...@@ -62,6 +64,12 @@ module QA
end end
end end
def click_merge_requests
within_sidebar do
click_link('Merge Requests')
end
end
def click_wiki def click_wiki
within_sidebar do within_sidebar do
click_link('Wiki') click_link('Wiki')
......
module QA
module Page
module Project
module Import
class Github < Page::Base
include Page::Component::Select2
view 'app/views/import/github/new.html.haml' do
element :personal_access_token_field, 'text_field_tag :personal_access_token'
element :list_repos_button, "submit_tag _('List your GitHub repositories')"
end
view 'app/views/import/_githubish_status.html.haml' do
element :project_import_row, 'data: { qa: { repo_path: repo.full_name } }'
element :project_namespace_select
element :project_namespace_field, 'select_tag :namespace_id'
element :project_path_field, 'text_field_tag :path, repo.name'
element :import_button, "_('Import')"
end
def add_personal_access_token(personal_access_token)
fill_in 'personal_access_token', with: personal_access_token
end
def list_repos
click_button 'List your GitHub repositories'
end
def import!(full_path, name)
choose_test_namespace(full_path)
set_path(full_path, name)
import_project(full_path)
end
private
def within_repo_path(full_path)
page.within(%Q(tr[data-qa-repo-path="#{full_path}"])) do
yield
end
end
def choose_test_namespace(full_path)
within_repo_path(full_path) do
click_element :project_namespace_select
end
select_item(Runtime::Namespace.path)
end
def set_path(full_path, name)
within_repo_path(full_path) do
fill_in 'path', with: name
end
end
def import_project(full_path)
within_repo_path(full_path) do
click_button 'Import'
end
end
end
end
end
end
end
...@@ -2,6 +2,12 @@ module QA ...@@ -2,6 +2,12 @@ module QA
module Page module Page
module Project module Project
class New < Page::Base class New < Page::Base
include Page::Component::Select2
view 'app/views/projects/new.html.haml' do
element :import_project_tab, "Import project"
end
view 'app/views/projects/_new_project_fields.html.haml' do view 'app/views/projects/_new_project_fields.html.haml' do
element :project_namespace_select element :project_namespace_select
element :project_namespace_field, /select :namespace_id.*class: 'select2/ element :project_namespace_field, /select :namespace_id.*class: 'select2/
...@@ -10,10 +16,18 @@ module QA ...@@ -10,10 +16,18 @@ module QA
element :project_create_button, "submit 'Create project'" element :project_create_button, "submit 'Create project'"
end end
view 'app/views/projects/_import_project_pane.html.haml' do
element :import_github, "icon('github', text: 'GitHub')"
end
def choose_test_namespace def choose_test_namespace
click_element :project_namespace_select click_element :project_namespace_select
find('ul.select2-result-sub > li', text: Runtime::Namespace.path).click select_item(Runtime::Namespace.path)
end
def go_to_import_project
click_on 'Import project'
end end
def choose_name(name) def choose_name(name)
...@@ -27,6 +41,10 @@ module QA ...@@ -27,6 +41,10 @@ module QA
def create_new_project def create_new_project
click_on 'Create project' click_on 'Create project'
end end
def go_to_github_import
click_link 'GitHub'
end
end end
end end
end end
......
...@@ -24,6 +24,10 @@ module QA ...@@ -24,6 +24,10 @@ module QA
element :branches_dropdown element :branches_dropdown
end end
view 'app/views/projects/_files.html.haml' do
element :tree_holder, '.tree-holder'
end
def project_name def project_name
find('.qa-project-name').text find('.qa-project-name').text
end end
...@@ -48,6 +52,12 @@ module QA ...@@ -48,6 +52,12 @@ module QA
click_element :create_merge_request click_element :create_merge_request
end end
def wait_for_import
wait(reload: true) do
has_css?('.tree-holder')
end
end
def go_to_new_issue def go_to_new_issue
click_element :new_menu_toggle click_element :new_menu_toggle
......
...@@ -68,6 +68,17 @@ module QA ...@@ -68,6 +68,17 @@ module QA
def has_gcloud_credentials? def has_gcloud_credentials?
%w[GCLOUD_ACCOUNT_KEY GCLOUD_ACCOUNT_EMAIL].none? { |var| ENV[var].to_s.empty? } %w[GCLOUD_ACCOUNT_KEY GCLOUD_ACCOUNT_EMAIL].none? { |var| ENV[var].to_s.empty? }
end end
# Specifies the token that can be used for the GitHub API
def github_access_token
ENV['GITHUB_ACCESS_TOKEN'].to_s.strip
end
def require_github_access_token!
return unless github_access_token.empty?
raise ArgumentError, "Please provide GITHUB_ACCESS_TOKEN"
end
end end
end end
end end
...@@ -16,7 +16,7 @@ module QA ...@@ -16,7 +16,7 @@ module QA
end end
def sandbox_name def sandbox_name
Runtime::Env.sandbox_name || 'gitlab-qa-sandbox' Runtime::Env.sandbox_name || 'gitlab-qa-sandbox-group'
end end
end end
end end
......
module QA
module Scenario
module Test
module Integration
class Github < Test::Instance
tags :github
def perform(address, *rspec_options)
# This test suite requires a GitHub personal access token
Runtime::Env.require_github_access_token!
super
end
end
end
end
end
end
module QA
describe 'user imports a GitHub repo', :core, :github do
let(:imported_project) do
Factory::Resource::ProjectImportedFromGithub.fabricate! do |project|
project.name = 'imported-project'
project.personal_access_token = Runtime::Env.github_access_token
project.github_repository_path = 'gitlab-qa/test-project'
end
end
after do
# We need to delete the imported project because it's impossible to import
# the same GitHub project twice for a given user.
api_client = Runtime::API::Client.new(:gitlab)
delete_project_request = Runtime::API::Request.new(api_client, "/projects/#{CGI.escape("#{Runtime::Namespace.path}/#{imported_project.name}")}")
delete delete_project_request.url
expect_status(202)
end
it 'user imports a GitHub repo' do
Runtime::Browser.visit(:gitlab, Page::Main::Login)
Page::Main::Login.act { sign_in_using_credentials }
imported_project # import the project
Page::Menu::Main.act { go_to_projects }
Page::Dashboard::Projects.perform do |dashboard|
dashboard.go_to_project(imported_project.name)
end
Page::Project::Show.act { wait_for_import }
verify_repository_import
verify_issues_import
verify_merge_requests_import
verify_labels_import
verify_milestones_import
verify_wiki_import
end
def verify_repository_import
expect(page).to have_content('This test project is used for automated GitHub import by GitLab QA.')
expect(page).to have_content(imported_project.name)
end
def verify_issues_import
Page::Menu::Side.act { click_issues }
expect(page).to have_content('This is a sample issue')
click_link 'This is a sample issue'
expect(page).to have_content('We should populate this project with issues, pull requests and wiki pages.')
# Comments
expect(page).to have_content('This is a comment from @rymai.')
Page::Issuable::Sidebar.perform do |issuable|
expect(issuable).to have_label('enhancement')
expect(issuable).to have_label('help wanted')
expect(issuable).to have_label('good first issue')
end
end
def verify_merge_requests_import
Page::Menu::Side.act { click_merge_requests }
expect(page).to have_content('Improve README.md')
click_link 'Improve README.md'
expect(page).to have_content('This improves the README file a bit.')
# Review comment are not supported yet
expect(page).not_to have_content('Really nice change.')
# Comments
expect(page).to have_content('Nice work! This is a comment from @rymai.')
# Diff comments
expect(page).to have_content('[Review comment] I like that!')
expect(page).to have_content('[Review comment] Nice blank line.')
expect(page).to have_content('[Single diff comment] Much better without this line!')
Page::Issuable::Sidebar.perform do |issuable|
expect(issuable).to have_label('bug')
expect(issuable).to have_label('enhancement')
end
end
def verify_labels_import
# TODO: Waiting on https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/19228
# to build upon it.
end
def verify_milestones_import
# TODO: Waiting on https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/18727
# to build upon it.
end
def verify_wiki_import
Page::Menu::Side.act { click_wiki }
expect(page).to have_content('Welcome to the test-project wiki!')
end
end
end
...@@ -76,4 +76,27 @@ describe QA::Runtime::Env do ...@@ -76,4 +76,27 @@ describe QA::Runtime::Env do
expect { described_class.user_type }.to raise_error(ArgumentError) expect { described_class.user_type }.to raise_error(ArgumentError)
end end
end end
describe '.github_access_token' do
it 'returns "" if GITHUB_ACCESS_TOKEN is not defined' do
expect(described_class.github_access_token).to eq('')
end
it 'returns stripped string if GITHUB_ACCESS_TOKEN is defined' do
stub_env('GITHUB_ACCESS_TOKEN', ' abc123 ')
expect(described_class.github_access_token).to eq('abc123')
end
end
describe '.require_github_access_token!' do
it 'raises ArgumentError if GITHUB_ACCESS_TOKEN is not defined' do
expect { described_class.require_github_access_token! }.to raise_error(ArgumentError)
end
it 'does not raise if GITHUB_ACCESS_TOKEN is defined' do
stub_env('GITHUB_ACCESS_TOKEN', ' abc123 ')
expect { described_class.require_github_access_token! }.not_to raise_error
end
end
end end
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 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);
});
});
});
This diff is collapsed.
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