Merge branch '35010-projects-nav-dropdown' into 'master'
Add dropdown to Projects nav item Closes #35010 See merge request !13866
This commit is contained in:
commit
6e4949dba1
33 changed files with 1921 additions and 18 deletions
|
@ -5,7 +5,7 @@ const Api = {
|
|||
groupPath: '/api/:version/groups/:id.json',
|
||||
namespacesPath: '/api/:version/namespaces.json',
|
||||
groupProjectsPath: '/api/:version/groups/:id/projects.json',
|
||||
projectsPath: '/api/:version/projects.json?simple=true',
|
||||
projectsPath: '/api/:version/projects.json',
|
||||
labelsPath: '/:namespace_path/:project_path/labels',
|
||||
licensePath: '/api/:version/templates/licenses/:key',
|
||||
gitignorePath: '/api/:version/templates/gitignores/:key',
|
||||
|
@ -58,6 +58,7 @@ const Api = {
|
|||
const defaults = {
|
||||
search: query,
|
||||
per_page: 20,
|
||||
simple: true,
|
||||
};
|
||||
|
||||
if (gon.current_user_id) {
|
||||
|
|
|
@ -2,3 +2,4 @@ import 'underscore';
|
|||
import './polyfills';
|
||||
import './jquery';
|
||||
import './bootstrap';
|
||||
import './vue';
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import Vue from 'vue';
|
||||
import './vue_resource_interceptor';
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
Vue.config.productionTip = false;
|
|
@ -132,6 +132,7 @@ import './project_new';
|
|||
import './project_select';
|
||||
import './project_show';
|
||||
import './project_variables';
|
||||
import './projects_dropdown';
|
||||
import './projects_list';
|
||||
import './syntax_highlight';
|
||||
import './render_math';
|
||||
|
|
157
app/assets/javascripts/projects_dropdown/components/app.vue
Normal file
157
app/assets/javascripts/projects_dropdown/components/app.vue
Normal file
|
@ -0,0 +1,157 @@
|
|||
<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();
|
||||
},
|
||||
},
|
||||
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);
|
||||
},
|
||||
},
|
||||
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);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<search/>
|
||||
<loading-icon
|
||||
class="loading-animation prepend-top-20"
|
||||
size="2"
|
||||
v-if="isLoadingProjects"
|
||||
:label="s__('ProjectsDropdown|Loading projects')"
|
||||
/>
|
||||
<div
|
||||
class="section-header"
|
||||
v-if="isFrequentsListVisible"
|
||||
>
|
||||
{{ 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>
|
|
@ -0,0 +1,57 @@
|
|||
<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
|
||||
class="section-empty"
|
||||
v-if="isListEmpty"
|
||||
>
|
||||
{{listEmptyMessage}}
|
||||
</li>
|
||||
<projects-list-item
|
||||
v-else
|
||||
v-for="(project, index) in projects"
|
||||
:key="index"
|
||||
:project-id="project.id"
|
||||
:project-name="project.name"
|
||||
:namespace="project.namespace"
|
||||
:web-url="project.webUrl"
|
||||
:avatar-url="project.avatarUrl"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,96 @@
|
|||
<script>
|
||||
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;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
class="projects-list-item-container"
|
||||
>
|
||||
<a
|
||||
class="clearfix"
|
||||
:href="webUrl"
|
||||
>
|
||||
<div
|
||||
class="project-item-avatar-container"
|
||||
>
|
||||
<img
|
||||
v-if="hasAvatar"
|
||||
class="avatar s32"
|
||||
:src="avatarUrl"
|
||||
/>
|
||||
<identicon
|
||||
v-else
|
||||
size-class="s32"
|
||||
:entity-id=projectId
|
||||
:entity-name="projectName"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="project-item-metadata-container"
|
||||
>
|
||||
<div
|
||||
class="project-title"
|
||||
:title="projectName"
|
||||
v-html="highlightedProjectName"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="project-namespace"
|
||||
:title="namespace"
|
||||
>
|
||||
{{namespace}}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
|
@ -0,0 +1,63 @@
|
|||
<script>
|
||||
import { s__ } from '../../locale';
|
||||
import projectsListItem from './projects_list_item.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
projectsListItem,
|
||||
},
|
||||
props: {
|
||||
matcher: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
projects: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
searchFailed: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
isListEmpty() {
|
||||
return this.projects.length === 0;
|
||||
},
|
||||
listEmptyMessage() {
|
||||
return this.searchFailed ?
|
||||
s__('ProjectsDropdown|Something went wrong on our end.') :
|
||||
s__('ProjectsDropdown|No projects matched your query');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="projects-list-search-container"
|
||||
>
|
||||
<ul
|
||||
class="list-unstyled"
|
||||
>
|
||||
<li
|
||||
v-if="isListEmpty"
|
||||
:class="{ 'section-failure': searchFailed }"
|
||||
class="section-empty"
|
||||
>
|
||||
{{ listEmptyMessage }}
|
||||
</li>
|
||||
<projects-list-item
|
||||
v-else
|
||||
v-for="(project, index) in projects"
|
||||
:key="index"
|
||||
:project-id="project.id"
|
||||
:project-name="project.name"
|
||||
:namespace="project.namespace"
|
||||
:web-url="project.webUrl"
|
||||
:avatar-url="project.avatarUrl"
|
||||
:matcher="matcher"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,64 @@
|
|||
<script>
|
||||
import _ from 'underscore';
|
||||
import eventHub from '../event_hub';
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
searchQuery: '',
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
searchQuery() {
|
||||
this.handleInput();
|
||||
},
|
||||
},
|
||||
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),
|
||||
},
|
||||
mounted() {
|
||||
eventHub.$on('dropdownOpen', this.setFocus);
|
||||
},
|
||||
beforeDestroy() {
|
||||
eventHub.$off('dropdownOpen', this.setFocus);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="search-input-container hidden-xs"
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
class="form-control"
|
||||
ref="search"
|
||||
v-model="searchQuery"
|
||||
:placeholder="s__('ProjectsDropdown|Search projects')"
|
||||
/>
|
||||
<i
|
||||
v-if="!searchQuery"
|
||||
class="search-icon fa fa-fw fa-search"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
10
app/assets/javascripts/projects_dropdown/constants.js
Normal file
10
app/assets/javascripts/projects_dropdown/constants.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
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';
|
3
app/assets/javascripts/projects_dropdown/event_hub.js
Normal file
3
app/assets/javascripts/projects_dropdown/event_hub.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
export default new Vue();
|
68
app/assets/javascripts/projects_dropdown/index.js
Normal file
68
app/assets/javascripts/projects_dropdown/index.js
Normal file
|
@ -0,0 +1,68 @@
|
|||
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('show.bs.dropdown', (e) => {
|
||||
const dropdownEl = $(e.currentTarget).find('.projects-dropdown-menu');
|
||||
dropdownEl.one('transitionend', () => {
|
||||
eventHub.$emit('dropdownOpen');
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-new
|
||||
new Vue({
|
||||
el,
|
||||
components: {
|
||||
projectsDropdownApp,
|
||||
},
|
||||
data() {
|
||||
const dataset = this.$options.el.dataset;
|
||||
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,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
|
@ -0,0 +1,132 @@
|
|||
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: false,
|
||||
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);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
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 = [];
|
||||
}
|
||||
}
|
|
@ -9,6 +9,11 @@ export default {
|
|||
type: String,
|
||||
required: true,
|
||||
},
|
||||
sizeClass: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 's40',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
/**
|
||||
|
@ -38,7 +43,8 @@ export default {
|
|||
|
||||
<template>
|
||||
<div
|
||||
class="avatar s40 identicon"
|
||||
class="avatar identicon"
|
||||
:class="sizeClass"
|
||||
:style="identiconStyles">
|
||||
{{identiconTitle}}
|
||||
</div>
|
||||
|
|
|
@ -21,6 +21,7 @@
|
|||
.append-right-default { margin-right: $gl-padding; }
|
||||
.append-right-20 { margin-right: 20px; }
|
||||
.append-bottom-0 { margin-bottom: 0; }
|
||||
.append-bottom-5 { margin-bottom: 5px; }
|
||||
.append-bottom-10 { margin-bottom: 10px; }
|
||||
.append-bottom-15 { margin-bottom: 15px; }
|
||||
.append-bottom-20 { margin-bottom: 20px; }
|
||||
|
|
|
@ -829,3 +829,152 @@
|
|||
}
|
||||
|
||||
@include new-style-dropdown('.js-namespace-select + ');
|
||||
|
||||
header.navbar-gitlab-new .header-content .dropdown-menu.projects-dropdown-menu {
|
||||
padding: 0;
|
||||
|
||||
@media (max-width: $screen-xs-max) {
|
||||
display: table;
|
||||
left: -50px;
|
||||
min-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.projects-dropdown-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 500px;
|
||||
height: 334px;
|
||||
|
||||
.project-dropdown-sidebar,
|
||||
.project-dropdown-content {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.loading-animation {
|
||||
color: $almost-black;
|
||||
}
|
||||
|
||||
.project-dropdown-sidebar {
|
||||
width: 30%;
|
||||
border-right: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.project-dropdown-content {
|
||||
position: relative;
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
@media (max-width: $screen-xs-max) {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
flex: 1;
|
||||
|
||||
.project-dropdown-sidebar,
|
||||
.project-dropdown-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.project-dropdown-sidebar {
|
||||
border-bottom: 1px solid $border-color;
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.projects-dropdown-container {
|
||||
.projects-list-frequent-container,
|
||||
.projects-list-search-container, {
|
||||
padding: 8px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section-header,
|
||||
.projects-list-frequent-container li.section-empty,
|
||||
.projects-list-search-container li.section-empty {
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.section-header,
|
||||
.projects-list-frequent-container li.section-empty,
|
||||
.projects-list-search-container li.section-empty {
|
||||
color: $gl-text-color-secondary;
|
||||
font-size: $gl-font-size;
|
||||
}
|
||||
|
||||
.projects-list-frequent-container,
|
||||
.projects-list-search-container {
|
||||
li.section-empty.section-failure {
|
||||
color: $callout-danger-color;
|
||||
}
|
||||
}
|
||||
|
||||
.search-input-container {
|
||||
position: relative;
|
||||
padding: 4px $gl-padding;
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
top: 13px;
|
||||
right: 25px;
|
||||
color: $md-area-border;
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
font-weight: 700;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.projects-list-search-container {
|
||||
height: 284px;
|
||||
}
|
||||
|
||||
@media (max-width: $screen-xs-max) {
|
||||
.projects-list-frequent-container {
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.projects-list-item-container {
|
||||
.project-item-avatar-container
|
||||
.project-item-metadata-container {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.project-title,
|
||||
.project-namespace {
|
||||
max-width: 250px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.project-item-avatar-container .avatar {
|
||||
border-color: $md-area-border;
|
||||
}
|
||||
}
|
||||
|
||||
.project-title {
|
||||
font-size: $gl-font-size;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.project-namespace {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
color: $gl-text-color-secondary;
|
||||
}
|
||||
|
||||
@media (max-width: $screen-xs-max) {
|
||||
.project-item-metadata-container {
|
||||
float: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
%ul.list-unstyled.navbar-sub-nav
|
||||
= nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: {class: "home"}) do
|
||||
= link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do
|
||||
= nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown" }) do
|
||||
%a{ href: '#', title: 'Projects', data: { toggle: 'dropdown' } }
|
||||
Projects
|
||||
= icon("chevron-down", class: "dropdown-chevron")
|
||||
.dropdown-menu.projects-dropdown-menu
|
||||
= render "layouts/nav/projects_dropdown/show"
|
||||
|
||||
= nav_link(controller: ['dashboard/groups', 'explore/groups']) do
|
||||
= link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', title: 'Groups' do
|
||||
|
@ -31,3 +34,8 @@
|
|||
%li.divider
|
||||
%li
|
||||
= link_to "Help", help_path, title: 'About GitLab CE'
|
||||
|
||||
-# Shortcut to Dashboard > Projects
|
||||
%li.hidden
|
||||
= link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do
|
||||
Projects
|
||||
|
|
15
app/views/layouts/nav/projects_dropdown/_show.html.haml
Normal file
15
app/views/layouts/nav/projects_dropdown/_show.html.haml
Normal file
|
@ -0,0 +1,15 @@
|
|||
- project_meta = { id: @project.id, name: @project.name, namespace: @project.name_with_namespace, web_url: @project.web_url, avatar_url: @project.avatar_url } if @project&.persisted?
|
||||
.projects-dropdown-container
|
||||
.project-dropdown-sidebar
|
||||
%ul
|
||||
= nav_link(path: 'dashboard/projects#index') do
|
||||
= link_to dashboard_projects_path do
|
||||
= _('Your projects')
|
||||
= nav_link(path: 'projects#starred') do
|
||||
= link_to starred_dashboard_projects_path do
|
||||
= _('Starred projects')
|
||||
= nav_link(path: 'projects#trending') do
|
||||
= link_to explore_root_path do
|
||||
= _('Explore projects')
|
||||
.project-dropdown-content
|
||||
#js-projects-dropdown{ data: { user_name: current_user.username, project: project_meta } }
|
5
changelogs/unreleased/35010-projects-nav-dropdown.yml
Normal file
5
changelogs/unreleased/35010-projects-nav-dropdown.yml
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add dropdown to Projects nav item
|
||||
merge_request: 13866
|
||||
author:
|
||||
type: added
|
|
@ -30,7 +30,7 @@ var config = {
|
|||
blob: './blob_edit/blob_bundle.js',
|
||||
boards: './boards/boards_bundle.js',
|
||||
common: './commons/index.js',
|
||||
common_vue: ['vue', './vue_shared/common_vue.js'],
|
||||
common_vue: './vue_shared/vue_resource_interceptor.js',
|
||||
common_d3: ['d3'],
|
||||
cycle_analytics: './cycle_analytics/cycle_analytics_bundle.js',
|
||||
commit_pipelines: './commit/pipelines/pipelines_bundle.js',
|
||||
|
|
|
@ -7,8 +7,8 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: gitlab 1.0.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2017-08-24 09:29+0200\n"
|
||||
"PO-Revision-Date: 2017-08-24 09:29+0200\n"
|
||||
"POT-Creation-Date: 2017-08-31 17:34+0530\n"
|
||||
"PO-Revision-Date: 2017-08-31 17:34+0530\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"Language: \n"
|
||||
|
@ -427,6 +427,9 @@ msgstr ""
|
|||
msgid "Every week (Sundays at 4:00am)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Explore projects"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to change the owner"
|
||||
msgstr ""
|
||||
|
||||
|
@ -837,6 +840,27 @@ msgstr ""
|
|||
msgid "ProjectNetworkGraph|Graph"
|
||||
msgstr ""
|
||||
|
||||
msgid "ProjectsDropdown|Frequently visited"
|
||||
msgstr ""
|
||||
|
||||
msgid "ProjectsDropdown|Loading projects"
|
||||
msgstr ""
|
||||
|
||||
msgid "ProjectsDropdown|No projects matched your query"
|
||||
msgstr ""
|
||||
|
||||
msgid "ProjectsDropdown|Projects you visit often will appear here"
|
||||
msgstr ""
|
||||
|
||||
msgid "ProjectsDropdown|Search projects"
|
||||
msgstr ""
|
||||
|
||||
msgid "ProjectsDropdown|Something went wrong on our end."
|
||||
msgstr ""
|
||||
|
||||
msgid "ProjectsDropdown|This feature requires browser localStorage support"
|
||||
msgstr ""
|
||||
|
||||
msgid "Push events"
|
||||
msgstr ""
|
||||
|
||||
|
@ -950,6 +974,9 @@ msgstr ""
|
|||
msgid "StarProject|Star"
|
||||
msgstr ""
|
||||
|
||||
msgid "Starred projects"
|
||||
msgstr ""
|
||||
|
||||
msgid "Start a %{new_merge_request} with these changes"
|
||||
msgstr ""
|
||||
|
||||
|
@ -1271,6 +1298,9 @@ msgstr ""
|
|||
msgid "Your name"
|
||||
msgstr ""
|
||||
|
||||
msgid "Your projects"
|
||||
msgstr ""
|
||||
|
||||
msgid "day"
|
||||
msgid_plural "days"
|
||||
msgstr[0] ""
|
||||
|
|
|
@ -101,12 +101,13 @@ describe('Api', () => {
|
|||
it('fetches projects with membership when logged in', (done) => {
|
||||
const query = 'dummy query';
|
||||
const options = { unused: 'option' };
|
||||
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json?simple=true`;
|
||||
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json`;
|
||||
window.gon.current_user_id = 1;
|
||||
const expectedData = Object.assign({
|
||||
search: query,
|
||||
per_page: 20,
|
||||
membership: true,
|
||||
simple: true,
|
||||
}, options);
|
||||
spyOn(jQuery, 'ajax').and.callFake((request) => {
|
||||
expect(request.url).toEqual(expectedUrl);
|
||||
|
@ -124,10 +125,11 @@ describe('Api', () => {
|
|||
it('fetches projects without membership when not logged in', (done) => {
|
||||
const query = 'dummy query';
|
||||
const options = { unused: 'option' };
|
||||
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json?simple=true`;
|
||||
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json`;
|
||||
const expectedData = Object.assign({
|
||||
search: query,
|
||||
per_page: 20,
|
||||
simple: true,
|
||||
}, options);
|
||||
spyOn(jQuery, 'ajax').and.callFake((request) => {
|
||||
expect(request.url).toEqual(expectedUrl);
|
||||
|
|
|
@ -41,12 +41,13 @@ describe('Project Title', () => {
|
|||
window.gon.current_user_id = 1;
|
||||
$('.js-projects-dropdown-toggle').click();
|
||||
expect($menu).toHaveClass('open');
|
||||
expect(reqUrl).toBe(`/api/${dummyApiVersion}/projects.json?simple=true`);
|
||||
expect(reqUrl).toBe(`/api/${dummyApiVersion}/projects.json`);
|
||||
expect(reqData).toEqual({
|
||||
search: '',
|
||||
order_by: 'last_activity_at',
|
||||
per_page: 20,
|
||||
membership: true,
|
||||
simple: true,
|
||||
});
|
||||
$menu.find('.dropdown-menu-close-icon').click();
|
||||
expect($menu).not.toHaveClass('open');
|
||||
|
|
348
spec/javascripts/projects_dropdown/components/app_spec.js
Normal file
348
spec/javascripts/projects_dropdown/components/app_spec.js
Normal file
|
@ -0,0 +1,348 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
import bp from '~/breakpoints';
|
||||
import appComponent from '~/projects_dropdown/components/app.vue';
|
||||
import eventHub from '~/projects_dropdown/event_hub';
|
||||
import ProjectsStore from '~/projects_dropdown/store/projects_store';
|
||||
import ProjectsService from '~/projects_dropdown/service/projects_service';
|
||||
|
||||
import mountComponent from '../../helpers/vue_mount_component_helper';
|
||||
import { currentSession, mockProject, mockRawProject } from '../mock_data';
|
||||
|
||||
const createComponent = () => {
|
||||
gon.api_version = currentSession.apiVersion;
|
||||
const Component = Vue.extend(appComponent);
|
||||
const store = new ProjectsStore();
|
||||
const service = new ProjectsService(currentSession.username);
|
||||
|
||||
return mountComponent(Component, {
|
||||
store,
|
||||
service,
|
||||
currentUserName: currentSession.username,
|
||||
currentProject: currentSession.project,
|
||||
});
|
||||
};
|
||||
|
||||
const returnServicePromise = (data, failed) => new Promise((resolve, reject) => {
|
||||
if (failed) {
|
||||
reject(data);
|
||||
} else {
|
||||
resolve({
|
||||
json() {
|
||||
return data;
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('AppComponent', () => {
|
||||
describe('computed', () => {
|
||||
let vm;
|
||||
|
||||
beforeEach(() => {
|
||||
vm = createComponent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
describe('frequentProjects', () => {
|
||||
it('should return list of frequently accessed projects from store', () => {
|
||||
expect(vm.frequentProjects).toBeDefined();
|
||||
expect(vm.frequentProjects.length).toBe(0);
|
||||
|
||||
vm.store.setFrequentProjects([mockProject]);
|
||||
expect(vm.frequentProjects).toBeDefined();
|
||||
expect(vm.frequentProjects.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchProjects', () => {
|
||||
it('should return list of frequently accessed projects from store', () => {
|
||||
expect(vm.searchProjects).toBeDefined();
|
||||
expect(vm.searchProjects.length).toBe(0);
|
||||
|
||||
vm.store.setSearchedProjects([mockRawProject]);
|
||||
expect(vm.searchProjects).toBeDefined();
|
||||
expect(vm.searchProjects.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('methods', () => {
|
||||
let vm;
|
||||
|
||||
beforeEach(() => {
|
||||
vm = createComponent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
describe('toggleFrequentProjectsList', () => {
|
||||
it('should toggle props which control visibility of Frequent Projects list from state passed', () => {
|
||||
vm.toggleFrequentProjectsList(true);
|
||||
expect(vm.isLoadingProjects).toBeFalsy();
|
||||
expect(vm.isSearchListVisible).toBeFalsy();
|
||||
expect(vm.isFrequentsListVisible).toBeTruthy();
|
||||
|
||||
vm.toggleFrequentProjectsList(false);
|
||||
expect(vm.isLoadingProjects).toBeTruthy();
|
||||
expect(vm.isSearchListVisible).toBeTruthy();
|
||||
expect(vm.isFrequentsListVisible).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleSearchProjectsList', () => {
|
||||
it('should toggle props which control visibility of Searched Projects list from state passed', () => {
|
||||
vm.toggleSearchProjectsList(true);
|
||||
expect(vm.isLoadingProjects).toBeFalsy();
|
||||
expect(vm.isFrequentsListVisible).toBeFalsy();
|
||||
expect(vm.isSearchListVisible).toBeTruthy();
|
||||
|
||||
vm.toggleSearchProjectsList(false);
|
||||
expect(vm.isLoadingProjects).toBeTruthy();
|
||||
expect(vm.isFrequentsListVisible).toBeTruthy();
|
||||
expect(vm.isSearchListVisible).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleLoader', () => {
|
||||
it('should toggle props which control visibility of list loading animation from state passed', () => {
|
||||
vm.toggleLoader(true);
|
||||
expect(vm.isFrequentsListVisible).toBeFalsy();
|
||||
expect(vm.isSearchListVisible).toBeFalsy();
|
||||
expect(vm.isLoadingProjects).toBeTruthy();
|
||||
|
||||
vm.toggleLoader(false);
|
||||
expect(vm.isFrequentsListVisible).toBeTruthy();
|
||||
expect(vm.isSearchListVisible).toBeTruthy();
|
||||
expect(vm.isLoadingProjects).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchFrequentProjects', () => {
|
||||
it('should set props for loading animation to `true` while frequent projects list is being loaded', () => {
|
||||
spyOn(vm, 'toggleLoader');
|
||||
|
||||
vm.fetchFrequentProjects();
|
||||
expect(vm.isLocalStorageFailed).toBeFalsy();
|
||||
expect(vm.toggleLoader).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should set props for loading animation to `false` and props for frequent projects list to `true` once data is loaded', () => {
|
||||
const mockData = [mockProject];
|
||||
|
||||
spyOn(vm.service, 'getFrequentProjects').and.returnValue(mockData);
|
||||
spyOn(vm.store, 'setFrequentProjects');
|
||||
spyOn(vm, 'toggleFrequentProjectsList');
|
||||
|
||||
vm.fetchFrequentProjects();
|
||||
expect(vm.service.getFrequentProjects).toHaveBeenCalled();
|
||||
expect(vm.store.setFrequentProjects).toHaveBeenCalledWith(mockData);
|
||||
expect(vm.toggleFrequentProjectsList).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should set props for failure message to `true` when method fails to fetch frequent projects list', () => {
|
||||
spyOn(vm.service, 'getFrequentProjects').and.returnValue(null);
|
||||
spyOn(vm.store, 'setFrequentProjects');
|
||||
spyOn(vm, 'toggleFrequentProjectsList');
|
||||
|
||||
expect(vm.isLocalStorageFailed).toBeFalsy();
|
||||
|
||||
vm.fetchFrequentProjects();
|
||||
expect(vm.service.getFrequentProjects).toHaveBeenCalled();
|
||||
expect(vm.store.setFrequentProjects).toHaveBeenCalledWith([]);
|
||||
expect(vm.toggleFrequentProjectsList).toHaveBeenCalledWith(true);
|
||||
expect(vm.isLocalStorageFailed).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should set props for search results list to `true` if search query was already made previously', () => {
|
||||
spyOn(bp, 'getBreakpointSize').and.returnValue('md');
|
||||
spyOn(vm.service, 'getFrequentProjects');
|
||||
spyOn(vm, 'toggleSearchProjectsList');
|
||||
|
||||
vm.searchQuery = 'test';
|
||||
vm.fetchFrequentProjects();
|
||||
expect(vm.service.getFrequentProjects).not.toHaveBeenCalled();
|
||||
expect(vm.toggleSearchProjectsList).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should set props for frequent projects list to `true` if search query was already made but screen size is less than 768px', () => {
|
||||
spyOn(bp, 'getBreakpointSize').and.returnValue('sm');
|
||||
spyOn(vm, 'toggleSearchProjectsList');
|
||||
spyOn(vm.service, 'getFrequentProjects');
|
||||
|
||||
vm.searchQuery = 'test';
|
||||
vm.fetchFrequentProjects();
|
||||
expect(vm.service.getFrequentProjects).toHaveBeenCalled();
|
||||
expect(vm.toggleSearchProjectsList).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchSearchedProjects', () => {
|
||||
const searchQuery = 'test';
|
||||
|
||||
it('should perform search with provided search query', (done) => {
|
||||
const mockData = [mockRawProject];
|
||||
spyOn(vm, 'toggleLoader');
|
||||
spyOn(vm, 'toggleSearchProjectsList');
|
||||
spyOn(vm.service, 'getSearchedProjects').and.returnValue(returnServicePromise(mockData));
|
||||
spyOn(vm.store, 'setSearchedProjects');
|
||||
|
||||
vm.fetchSearchedProjects(searchQuery);
|
||||
setTimeout(() => {
|
||||
expect(vm.searchQuery).toBe(searchQuery);
|
||||
expect(vm.toggleLoader).toHaveBeenCalledWith(true);
|
||||
expect(vm.service.getSearchedProjects).toHaveBeenCalledWith(searchQuery);
|
||||
expect(vm.toggleSearchProjectsList).toHaveBeenCalledWith(true);
|
||||
expect(vm.store.setSearchedProjects).toHaveBeenCalledWith(mockData);
|
||||
done();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
it('should update props for showing search failure', (done) => {
|
||||
spyOn(vm, 'toggleSearchProjectsList');
|
||||
spyOn(vm.service, 'getSearchedProjects').and.returnValue(returnServicePromise({}, true));
|
||||
|
||||
vm.fetchSearchedProjects(searchQuery);
|
||||
setTimeout(() => {
|
||||
expect(vm.searchQuery).toBe(searchQuery);
|
||||
expect(vm.service.getSearchedProjects).toHaveBeenCalledWith(searchQuery);
|
||||
expect(vm.isSearchFailed).toBeTruthy();
|
||||
expect(vm.toggleSearchProjectsList).toHaveBeenCalledWith(true);
|
||||
done();
|
||||
}, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logCurrentProjectAccess', () => {
|
||||
it('should log current project access via service', (done) => {
|
||||
spyOn(vm.service, 'logProjectAccess');
|
||||
|
||||
vm.currentProject = mockProject;
|
||||
vm.logCurrentProjectAccess();
|
||||
|
||||
setTimeout(() => {
|
||||
expect(vm.service.logProjectAccess).toHaveBeenCalledWith(mockProject);
|
||||
done();
|
||||
}, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSearchClear', () => {
|
||||
it('should show frequent projects list when search input is cleared', () => {
|
||||
spyOn(vm.store, 'clearSearchedProjects');
|
||||
spyOn(vm, 'toggleFrequentProjectsList');
|
||||
|
||||
vm.handleSearchClear();
|
||||
|
||||
expect(vm.toggleFrequentProjectsList).toHaveBeenCalledWith(true);
|
||||
expect(vm.store.clearSearchedProjects).toHaveBeenCalled();
|
||||
expect(vm.searchQuery).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSearchFailure', () => {
|
||||
it('should show failure message within dropdown', () => {
|
||||
spyOn(vm, 'toggleSearchProjectsList');
|
||||
|
||||
vm.handleSearchFailure();
|
||||
expect(vm.toggleSearchProjectsList).toHaveBeenCalledWith(true);
|
||||
expect(vm.isSearchFailed).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('created', () => {
|
||||
it('should bind event listeners on eventHub', (done) => {
|
||||
spyOn(eventHub, '$on');
|
||||
|
||||
createComponent().$mount();
|
||||
|
||||
Vue.nextTick(() => {
|
||||
expect(eventHub.$on).toHaveBeenCalledWith('dropdownOpen', jasmine.any(Function));
|
||||
expect(eventHub.$on).toHaveBeenCalledWith('searchProjects', jasmine.any(Function));
|
||||
expect(eventHub.$on).toHaveBeenCalledWith('searchCleared', jasmine.any(Function));
|
||||
expect(eventHub.$on).toHaveBeenCalledWith('searchFailed', jasmine.any(Function));
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('beforeDestroy', () => {
|
||||
it('should unbind event listeners on eventHub', (done) => {
|
||||
const vm = createComponent();
|
||||
spyOn(eventHub, '$off');
|
||||
|
||||
vm.$mount();
|
||||
vm.$destroy();
|
||||
|
||||
Vue.nextTick(() => {
|
||||
expect(eventHub.$off).toHaveBeenCalledWith('dropdownOpen', jasmine.any(Function));
|
||||
expect(eventHub.$off).toHaveBeenCalledWith('searchProjects', jasmine.any(Function));
|
||||
expect(eventHub.$off).toHaveBeenCalledWith('searchCleared', jasmine.any(Function));
|
||||
expect(eventHub.$off).toHaveBeenCalledWith('searchFailed', jasmine.any(Function));
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('template', () => {
|
||||
let vm;
|
||||
|
||||
beforeEach(() => {
|
||||
vm = createComponent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
it('should render search input', () => {
|
||||
expect(vm.$el.querySelector('.search-input-container')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should render loading animation', (done) => {
|
||||
vm.toggleLoader(true);
|
||||
Vue.nextTick(() => {
|
||||
const loadingEl = vm.$el.querySelector('.loading-animation');
|
||||
|
||||
expect(loadingEl).toBeDefined();
|
||||
expect(loadingEl.classList.contains('prepend-top-20')).toBeTruthy();
|
||||
expect(loadingEl.querySelector('i').getAttribute('aria-label')).toBe('Loading projects');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render frequent projects list header', (done) => {
|
||||
vm.toggleFrequentProjectsList(true);
|
||||
Vue.nextTick(() => {
|
||||
const sectionHeaderEl = vm.$el.querySelector('.section-header');
|
||||
|
||||
expect(sectionHeaderEl).toBeDefined();
|
||||
expect(sectionHeaderEl.innerText.trim()).toBe('Frequently visited');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render frequent projects list', (done) => {
|
||||
vm.toggleFrequentProjectsList(true);
|
||||
Vue.nextTick(() => {
|
||||
expect(vm.$el.querySelector('.projects-list-frequent-container')).toBeDefined();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render searched projects list', (done) => {
|
||||
vm.toggleSearchProjectsList(true);
|
||||
Vue.nextTick(() => {
|
||||
expect(vm.$el.querySelector('.section-header')).toBe(null);
|
||||
expect(vm.$el.querySelector('.projects-list-search-container')).toBeDefined();
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,72 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
import projectsListFrequentComponent from '~/projects_dropdown/components/projects_list_frequent.vue';
|
||||
|
||||
import mountComponent from '../../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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,65 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
import projectsListItemComponent from '~/projects_dropdown/components/projects_list_item.vue';
|
||||
|
||||
import mountComponent from '../../helpers/vue_mount_component_helper';
|
||||
import { mockProject } from '../mock_data';
|
||||
|
||||
const createComponent = () => {
|
||||
const Component = Vue.extend(projectsListItemComponent);
|
||||
|
||||
return mountComponent(Component, {
|
||||
projectId: mockProject.id,
|
||||
projectName: mockProject.name,
|
||||
namespace: mockProject.namespace,
|
||||
webUrl: mockProject.webUrl,
|
||||
avatarUrl: mockProject.avatarUrl,
|
||||
});
|
||||
};
|
||||
|
||||
describe('ProjectsListItemComponent', () => {
|
||||
let vm;
|
||||
|
||||
beforeEach(() => {
|
||||
vm = createComponent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
describe('computed', () => {
|
||||
describe('hasAvatar', () => {
|
||||
it('should return `true` or `false` if whether avatar is present or not', () => {
|
||||
vm.avatarUrl = 'path/to/avatar.png';
|
||||
expect(vm.hasAvatar).toBeTruthy();
|
||||
|
||||
vm.avatarUrl = null;
|
||||
expect(vm.hasAvatar).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('highlightedProjectName', () => {
|
||||
it('should enclose part of project name in <b> & </b> which matches with `matcher` prop', () => {
|
||||
vm.matcher = 'lab';
|
||||
expect(vm.highlightedProjectName).toContain('<b>Lab</b>');
|
||||
});
|
||||
|
||||
it('should return project name as it is if `matcher` is not available', () => {
|
||||
vm.matcher = null;
|
||||
expect(vm.highlightedProjectName).toBe(mockProject.name);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('template', () => {
|
||||
it('should render component element', () => {
|
||||
expect(vm.$el.classList.contains('projects-list-item-container')).toBeTruthy();
|
||||
expect(vm.$el.querySelectorAll('a').length).toBe(1);
|
||||
expect(vm.$el.querySelectorAll('.project-item-avatar-container').length).toBe(1);
|
||||
expect(vm.$el.querySelectorAll('.project-item-metadata-container').length).toBe(1);
|
||||
expect(vm.$el.querySelectorAll('.project-title').length).toBe(1);
|
||||
expect(vm.$el.querySelectorAll('.project-namespace').length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,84 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
import projectsListSearchComponent from '~/projects_dropdown/components/projects_list_search.vue';
|
||||
|
||||
import mountComponent from '../../helpers/vue_mount_component_helper';
|
||||
import { mockProject } from '../mock_data';
|
||||
|
||||
const createComponent = () => {
|
||||
const Component = Vue.extend(projectsListSearchComponent);
|
||||
|
||||
return mountComponent(Component, {
|
||||
projects: [mockProject],
|
||||
matcher: 'lab',
|
||||
searchFailed: false,
|
||||
});
|
||||
};
|
||||
|
||||
describe('ProjectsListSearchComponent', () => {
|
||||
let vm;
|
||||
|
||||
beforeEach(() => {
|
||||
vm = createComponent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
describe('computed', () => {
|
||||
describe('isListEmpty', () => {
|
||||
it('should return `true` or `false` representing whether if `projects` is empty of not', () => {
|
||||
vm.projects = [];
|
||||
expect(vm.isListEmpty).toBeTruthy();
|
||||
|
||||
vm.projects = [mockProject];
|
||||
expect(vm.isListEmpty).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listEmptyMessage', () => {
|
||||
it('should return appropriate empty list message based on value of `searchFailed` prop', () => {
|
||||
vm.searchFailed = true;
|
||||
expect(vm.listEmptyMessage).toBe('Something went wrong on our end.');
|
||||
|
||||
vm.searchFailed = false;
|
||||
expect(vm.listEmptyMessage).toBe('No projects matched your query');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('template', () => {
|
||||
it('should render component element with list of projects', (done) => {
|
||||
vm.projects = [mockProject];
|
||||
|
||||
Vue.nextTick(() => {
|
||||
expect(vm.$el.classList.contains('projects-list-search-container')).toBeTruthy();
|
||||
expect(vm.$el.querySelectorAll('ul.list-unstyled').length).toBe(1);
|
||||
expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(1);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render component element with empty message', (done) => {
|
||||
vm.projects = [];
|
||||
|
||||
Vue.nextTick(() => {
|
||||
expect(vm.$el.querySelectorAll('li.section-empty').length).toBe(1);
|
||||
expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(0);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render component element with failure message', (done) => {
|
||||
vm.searchFailed = true;
|
||||
vm.projects = [];
|
||||
|
||||
Vue.nextTick(() => {
|
||||
expect(vm.$el.querySelectorAll('li.section-empty.section-failure').length).toBe(1);
|
||||
expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(0);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
101
spec/javascripts/projects_dropdown/components/search_spec.js
Normal file
101
spec/javascripts/projects_dropdown/components/search_spec.js
Normal file
|
@ -0,0 +1,101 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
import searchComponent from '~/projects_dropdown/components/search.vue';
|
||||
import eventHub from '~/projects_dropdown/event_hub';
|
||||
|
||||
import mountComponent from '../../helpers/vue_mount_component_helper';
|
||||
|
||||
const createComponent = () => {
|
||||
const Component = Vue.extend(searchComponent);
|
||||
|
||||
return mountComponent(Component);
|
||||
};
|
||||
|
||||
describe('SearchComponent', () => {
|
||||
describe('methods', () => {
|
||||
let vm;
|
||||
|
||||
beforeEach(() => {
|
||||
vm = createComponent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
describe('setFocus', () => {
|
||||
it('should set focus to search input', () => {
|
||||
spyOn(vm.$refs.search, 'focus');
|
||||
|
||||
vm.setFocus();
|
||||
expect(vm.$refs.search.focus).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitSearchEvents', () => {
|
||||
it('should emit `searchProjects` event via eventHub when `searchQuery` present', () => {
|
||||
const searchQuery = 'test';
|
||||
spyOn(eventHub, '$emit');
|
||||
vm.searchQuery = searchQuery;
|
||||
vm.emitSearchEvents();
|
||||
expect(eventHub.$emit).toHaveBeenCalledWith('searchProjects', searchQuery);
|
||||
});
|
||||
|
||||
it('should emit `searchCleared` event via eventHub when `searchQuery` is cleared', () => {
|
||||
spyOn(eventHub, '$emit');
|
||||
vm.searchQuery = '';
|
||||
vm.emitSearchEvents();
|
||||
expect(eventHub.$emit).toHaveBeenCalledWith('searchCleared');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mounted', () => {
|
||||
it('should listen `dropdownOpen` event', (done) => {
|
||||
spyOn(eventHub, '$on');
|
||||
createComponent();
|
||||
|
||||
Vue.nextTick(() => {
|
||||
expect(eventHub.$on).toHaveBeenCalledWith('dropdownOpen', jasmine.any(Function));
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('beforeDestroy', () => {
|
||||
it('should unbind event listeners on eventHub', (done) => {
|
||||
const vm = createComponent();
|
||||
spyOn(eventHub, '$off');
|
||||
|
||||
vm.$mount();
|
||||
vm.$destroy();
|
||||
|
||||
Vue.nextTick(() => {
|
||||
expect(eventHub.$off).toHaveBeenCalledWith('dropdownOpen', jasmine.any(Function));
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('template', () => {
|
||||
let vm;
|
||||
|
||||
beforeEach(() => {
|
||||
vm = createComponent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
it('should render component element', () => {
|
||||
const inputEl = vm.$el.querySelector('input.form-control');
|
||||
|
||||
expect(vm.$el.classList.contains('search-input-container')).toBeTruthy();
|
||||
expect(vm.$el.classList.contains('hidden-xs')).toBeTruthy();
|
||||
expect(inputEl).not.toBe(null);
|
||||
expect(inputEl.getAttribute('placeholder')).toBe('Search projects');
|
||||
expect(vm.$el.querySelector('.search-icon')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
96
spec/javascripts/projects_dropdown/mock_data.js
Normal file
96
spec/javascripts/projects_dropdown/mock_data.js
Normal file
|
@ -0,0 +1,96 @@
|
|||
export const currentSession = {
|
||||
username: 'root',
|
||||
storageKey: 'root/frequent-projects',
|
||||
apiVersion: 'v4',
|
||||
project: {
|
||||
id: 1,
|
||||
name: 'dummy-project',
|
||||
namespace: 'SamepleGroup / Dummy-Project',
|
||||
webUrl: 'http://127.0.0.1/samplegroup/dummy-project',
|
||||
avatarUrl: null,
|
||||
lastAccessedOn: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
export const mockProject = {
|
||||
id: 1,
|
||||
name: 'GitLab Community Edition',
|
||||
namespace: 'gitlab-org / gitlab-ce',
|
||||
webUrl: 'http://127.0.0.1:3000/gitlab-org/gitlab-ce',
|
||||
avatarUrl: null,
|
||||
};
|
||||
|
||||
export const mockRawProject = {
|
||||
id: 1,
|
||||
name: 'GitLab Community Edition',
|
||||
name_with_namespace: 'gitlab-org / gitlab-ce',
|
||||
web_url: 'http://127.0.0.1:3000/gitlab-org/gitlab-ce',
|
||||
avatar_url: null,
|
||||
};
|
||||
|
||||
export const mockFrequents = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'GitLab Community Edition',
|
||||
namespace: 'gitlab-org / gitlab-ce',
|
||||
webUrl: 'http://127.0.0.1:3000/gitlab-org/gitlab-ce',
|
||||
avatarUrl: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'GitLab CI',
|
||||
namespace: 'gitlab-org / gitlab-ci',
|
||||
webUrl: 'http://127.0.0.1:3000/gitlab-org/gitlab-ci',
|
||||
avatarUrl: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Typeahead.Js',
|
||||
namespace: 'twitter / typeahead-js',
|
||||
webUrl: 'http://127.0.0.1:3000/twitter/typeahead-js',
|
||||
avatarUrl: '/uploads/-/system/project/avatar/7/TWBS.png',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Intel',
|
||||
namespace: 'platform / hardware / bsp / intel',
|
||||
webUrl: 'http://127.0.0.1:3000/platform/hardware/bsp/intel',
|
||||
avatarUrl: null,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'v4.4',
|
||||
namespace: 'platform / hardware / bsp / kernel / common / v4.4',
|
||||
webUrl: 'http://localhost:3000/platform/hardware/bsp/kernel/common/v4.4',
|
||||
avatarUrl: null,
|
||||
},
|
||||
];
|
||||
|
||||
export const unsortedFrequents = [
|
||||
{ id: 1, frequency: 12, lastAccessedOn: 1491400843391 },
|
||||
{ id: 2, frequency: 14, lastAccessedOn: 1488240890738 },
|
||||
{ id: 3, frequency: 44, lastAccessedOn: 1497675908472 },
|
||||
{ id: 4, frequency: 8, lastAccessedOn: 1497979281815 },
|
||||
{ id: 5, frequency: 34, lastAccessedOn: 1488089211943 },
|
||||
{ id: 6, frequency: 14, lastAccessedOn: 1493517292488 },
|
||||
{ id: 7, frequency: 42, lastAccessedOn: 1486815299875 },
|
||||
{ id: 8, frequency: 33, lastAccessedOn: 1500762279114 },
|
||||
{ id: 10, frequency: 46, lastAccessedOn: 1483251641543 },
|
||||
];
|
||||
|
||||
/**
|
||||
* This const has a specific order which tests authenticity
|
||||
* of `ProjectsService.getTopFrequentProjects` method so
|
||||
* DO NOT change order of items in this const.
|
||||
*/
|
||||
export const sortedFrequents = [
|
||||
{ id: 10, frequency: 46, lastAccessedOn: 1483251641543 },
|
||||
{ id: 3, frequency: 44, lastAccessedOn: 1497675908472 },
|
||||
{ id: 7, frequency: 42, lastAccessedOn: 1486815299875 },
|
||||
{ id: 5, frequency: 34, lastAccessedOn: 1488089211943 },
|
||||
{ id: 8, frequency: 33, lastAccessedOn: 1500762279114 },
|
||||
{ id: 6, frequency: 14, lastAccessedOn: 1493517292488 },
|
||||
{ id: 2, frequency: 14, lastAccessedOn: 1488240890738 },
|
||||
{ id: 1, frequency: 12, lastAccessedOn: 1491400843391 },
|
||||
{ id: 4, frequency: 8, lastAccessedOn: 1497979281815 },
|
||||
];
|
|
@ -0,0 +1,178 @@
|
|||
import Vue from 'vue';
|
||||
import VueResource from 'vue-resource';
|
||||
|
||||
import bp from '~/breakpoints';
|
||||
import ProjectsService from '~/projects_dropdown/service/projects_service';
|
||||
import { FREQUENT_PROJECTS } from '~/projects_dropdown/constants';
|
||||
import { currentSession, unsortedFrequents, sortedFrequents } from '../mock_data';
|
||||
|
||||
Vue.use(VueResource);
|
||||
|
||||
FREQUENT_PROJECTS.MAX_COUNT = 3;
|
||||
|
||||
describe('ProjectsService', () => {
|
||||
let service;
|
||||
|
||||
beforeEach(() => {
|
||||
gon.api_version = currentSession.apiVersion;
|
||||
service = new ProjectsService(currentSession.username);
|
||||
});
|
||||
|
||||
describe('contructor', () => {
|
||||
it('should initialize default properties of class', () => {
|
||||
expect(service.isLocalStorageAvailable).toBeTruthy();
|
||||
expect(service.currentUserName).toBe(currentSession.username);
|
||||
expect(service.storageKey).toBe(currentSession.storageKey);
|
||||
expect(service.projectsPath).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSearchedProjects', () => {
|
||||
it('should return promise from VueResource HTTP GET', () => {
|
||||
spyOn(service.projectsPath, 'get').and.stub();
|
||||
|
||||
const searchQuery = 'lab';
|
||||
const queryParams = {
|
||||
simple: false,
|
||||
per_page: 20,
|
||||
membership: false,
|
||||
order_by: 'last_activity_at',
|
||||
search: searchQuery,
|
||||
};
|
||||
|
||||
service.getSearchedProjects(searchQuery);
|
||||
expect(service.projectsPath.get).toHaveBeenCalledWith(queryParams);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logProjectAccess', () => {
|
||||
let storage;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = {};
|
||||
|
||||
spyOn(window.localStorage, 'setItem').and.callFake((storageKey, value) => {
|
||||
storage[storageKey] = value;
|
||||
});
|
||||
|
||||
spyOn(window.localStorage, 'getItem').and.callFake((storageKey) => {
|
||||
if (storage[storageKey]) {
|
||||
return storage[storageKey];
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a project store if it does not exist and adds a project', () => {
|
||||
service.logProjectAccess(currentSession.project);
|
||||
|
||||
const projects = JSON.parse(storage[currentSession.storageKey]);
|
||||
expect(projects.length).toBe(1);
|
||||
expect(projects[0].frequency).toBe(1);
|
||||
expect(projects[0].lastAccessedOn).toBeDefined();
|
||||
});
|
||||
|
||||
it('should prevent inserting same report multiple times into store', () => {
|
||||
service.logProjectAccess(currentSession.project);
|
||||
service.logProjectAccess(currentSession.project);
|
||||
|
||||
const projects = JSON.parse(storage[currentSession.storageKey]);
|
||||
expect(projects.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should increase frequency of report if it was logged multiple times over the course of an hour', () => {
|
||||
let projects;
|
||||
spyOn(Math, 'abs').and.returnValue(3600001); // this will lead to `diff` > 1;
|
||||
service.logProjectAccess(currentSession.project);
|
||||
|
||||
projects = JSON.parse(storage[currentSession.storageKey]);
|
||||
expect(projects[0].frequency).toBe(1);
|
||||
|
||||
service.logProjectAccess(currentSession.project);
|
||||
projects = JSON.parse(storage[currentSession.storageKey]);
|
||||
expect(projects[0].frequency).toBe(2);
|
||||
expect(projects[0].lastAccessedOn).not.toBe(currentSession.project.lastAccessedOn);
|
||||
});
|
||||
|
||||
it('should always update project metadata', () => {
|
||||
let projects;
|
||||
const oldProject = {
|
||||
...currentSession.project,
|
||||
};
|
||||
|
||||
const newProject = {
|
||||
...currentSession.project,
|
||||
name: 'New Name',
|
||||
avatarUrl: 'new/avatar.png',
|
||||
namespace: 'New / Namespace',
|
||||
webUrl: 'http://localhost/new/web/url',
|
||||
};
|
||||
|
||||
service.logProjectAccess(oldProject);
|
||||
projects = JSON.parse(storage[currentSession.storageKey]);
|
||||
expect(projects[0].name).toBe(oldProject.name);
|
||||
expect(projects[0].avatarUrl).toBe(oldProject.avatarUrl);
|
||||
expect(projects[0].namespace).toBe(oldProject.namespace);
|
||||
expect(projects[0].webUrl).toBe(oldProject.webUrl);
|
||||
|
||||
service.logProjectAccess(newProject);
|
||||
projects = JSON.parse(storage[currentSession.storageKey]);
|
||||
expect(projects[0].name).toBe(newProject.name);
|
||||
expect(projects[0].avatarUrl).toBe(newProject.avatarUrl);
|
||||
expect(projects[0].namespace).toBe(newProject.namespace);
|
||||
expect(projects[0].webUrl).toBe(newProject.webUrl);
|
||||
});
|
||||
|
||||
it('should not add more than 20 projects in store', () => {
|
||||
for (let i = 1; i <= 5; i += 1) {
|
||||
const project = Object.assign(currentSession.project, { id: i });
|
||||
service.logProjectAccess(project);
|
||||
}
|
||||
|
||||
const projects = JSON.parse(storage[currentSession.storageKey]);
|
||||
expect(projects.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTopFrequentProjects', () => {
|
||||
let storage = {};
|
||||
|
||||
beforeEach(() => {
|
||||
storage[currentSession.storageKey] = JSON.stringify(unsortedFrequents);
|
||||
|
||||
spyOn(window.localStorage, 'getItem').and.callFake((storageKey) => {
|
||||
if (storage[storageKey]) {
|
||||
return storage[storageKey];
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
it('should return top 5 frequently accessed projects for desktop screens', () => {
|
||||
spyOn(bp, 'getBreakpointSize').and.returnValue('md');
|
||||
const frequentProjects = service.getTopFrequentProjects();
|
||||
|
||||
expect(frequentProjects.length).toBe(5);
|
||||
frequentProjects.forEach((project, index) => {
|
||||
expect(project.id).toBe(sortedFrequents[index].id);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return top 3 frequently accessed projects for mobile screens', () => {
|
||||
spyOn(bp, 'getBreakpointSize').and.returnValue('sm');
|
||||
const frequentProjects = service.getTopFrequentProjects();
|
||||
|
||||
expect(frequentProjects.length).toBe(3);
|
||||
frequentProjects.forEach((project, index) => {
|
||||
expect(project.id).toBe(sortedFrequents[index].id);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array if there are no projects available in store', () => {
|
||||
storage = {};
|
||||
expect(service.getTopFrequentProjects().length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,41 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,25 +1,30 @@
|
|||
import Vue from 'vue';
|
||||
import identiconComponent from '~/vue_shared/components/identicon.vue';
|
||||
|
||||
const createComponent = () => {
|
||||
const createComponent = (sizeClass) => {
|
||||
const Component = Vue.extend(identiconComponent);
|
||||
|
||||
return new Component({
|
||||
propsData: {
|
||||
entityId: 1,
|
||||
entityName: 'entity-name',
|
||||
sizeClass,
|
||||
},
|
||||
}).$mount();
|
||||
};
|
||||
|
||||
describe('IdenticonComponent', () => {
|
||||
let vm;
|
||||
|
||||
beforeEach(() => {
|
||||
vm = createComponent();
|
||||
});
|
||||
|
||||
describe('computed', () => {
|
||||
let vm;
|
||||
|
||||
beforeEach(() => {
|
||||
vm = createComponent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
describe('identiconStyles', () => {
|
||||
it('should return styles attribute value with `background-color` property', () => {
|
||||
vm.entityId = 4;
|
||||
|
@ -48,9 +53,20 @@ describe('IdenticonComponent', () => {
|
|||
|
||||
describe('template', () => {
|
||||
it('should render identicon', () => {
|
||||
const vm = createComponent();
|
||||
|
||||
expect(vm.$el.nodeName).toBe('DIV');
|
||||
expect(vm.$el.classList.contains('identicon')).toBeTruthy();
|
||||
expect(vm.$el.classList.contains('s40')).toBeTruthy();
|
||||
expect(vm.$el.getAttribute('style').indexOf('background-color') > -1).toBeTruthy();
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
it('should render identicon with provided sizing class', () => {
|
||||
const vm = createComponent('s32');
|
||||
|
||||
expect(vm.$el.classList.contains('s32')).toBeTruthy();
|
||||
vm.$destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue