diff options
author | Filipa Lacerda <filipa@gitlab.com> | 2017-09-05 13:19:27 +0000 |
---|---|---|
committer | Filipa Lacerda <filipa@gitlab.com> | 2017-09-05 13:19:27 +0000 |
commit | 6e4949dba199d9fbc3070225e51f07657a81fbd7 (patch) | |
tree | bbe57cd4c3480fef6e84a032ff43d0cb21db6456 | |
parent | 75d56cec1bb28af32f471d6ba39e09216bcad3ce (diff) | |
parent | de10551ed5df72dffe9c4dfc21eba61aa19db77e (diff) | |
download | gitlab-ce-6e4949dba199d9fbc3070225e51f07657a81fbd7.tar.gz |
Merge branch '35010-projects-nav-dropdown' into 'master'
Add dropdown to Projects nav item
Closes #35010
See merge request !13866
33 files changed, 1920 insertions, 17 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 78cb3def879..8acddd6194c 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -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) { diff --git a/app/assets/javascripts/commons/index.js b/app/assets/javascripts/commons/index.js index 6db8b3afbef..768453b28f1 100644 --- a/app/assets/javascripts/commons/index.js +++ b/app/assets/javascripts/commons/index.js @@ -2,3 +2,4 @@ import 'underscore'; import './polyfills'; import './jquery'; import './bootstrap'; +import './vue'; diff --git a/app/assets/javascripts/vue_shared/common_vue.js b/app/assets/javascripts/commons/vue.js index eb2a6071fda..8b62d78c043 100644 --- a/app/assets/javascripts/vue_shared/common_vue.js +++ b/app/assets/javascripts/commons/vue.js @@ -1,5 +1,4 @@ import Vue from 'vue'; -import './vue_resource_interceptor'; if (process.env.NODE_ENV !== 'production') { Vue.config.productionTip = false; diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index e44903f8daa..f14458c8d41 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -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'; diff --git a/app/assets/javascripts/projects_dropdown/components/app.vue b/app/assets/javascripts/projects_dropdown/components/app.vue new file mode 100644 index 00000000000..7606605be32 --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/components/app.vue @@ -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> diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue new file mode 100644 index 00000000000..093554cd0bc --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue @@ -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> diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue new file mode 100644 index 00000000000..fe5179de206 --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue @@ -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> diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue new file mode 100644 index 00000000000..fa5efef2919 --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue @@ -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> diff --git a/app/assets/javascripts/projects_dropdown/components/search.vue b/app/assets/javascripts/projects_dropdown/components/search.vue new file mode 100644 index 00000000000..b71997234e5 --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/components/search.vue @@ -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> diff --git a/app/assets/javascripts/projects_dropdown/constants.js b/app/assets/javascripts/projects_dropdown/constants.js new file mode 100644 index 00000000000..8937097184c --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/constants.js @@ -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'; diff --git a/app/assets/javascripts/projects_dropdown/event_hub.js b/app/assets/javascripts/projects_dropdown/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/projects_dropdown/index.js b/app/assets/javascripts/projects_dropdown/index.js new file mode 100644 index 00000000000..2660da3c558 --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/index.js @@ -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, + }, + }); + }, + }); +}); diff --git a/app/assets/javascripts/projects_dropdown/service/projects_service.js b/app/assets/javascripts/projects_dropdown/service/projects_service.js new file mode 100644 index 00000000000..fad956b4c26 --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/service/projects_service.js @@ -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); + } +} diff --git a/app/assets/javascripts/projects_dropdown/store/projects_store.js b/app/assets/javascripts/projects_dropdown/store/projects_store.js new file mode 100644 index 00000000000..ffefbe693f4 --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/store/projects_store.js @@ -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 = []; + } +} diff --git a/app/assets/javascripts/vue_shared/components/identicon.vue b/app/assets/javascripts/vue_shared/components/identicon.vue index 0edd820743f..7cf2e029cf6 100644 --- a/app/assets/javascripts/vue_shared/components/identicon.vue +++ b/app/assets/javascripts/vue_shared/components/identicon.vue @@ -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> diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 68a51c5a461..a85051642dd 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -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; } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 336ac096d60..e65a78b8dc3 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -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; + } + } +} diff --git a/app/views/layouts/nav/_new_dashboard.html.haml b/app/views/layouts/nav/_new_dashboard.html.haml index cfdfcbebc9f..88d164cac32 100644 --- a/app/views/layouts/nav/_new_dashboard.html.haml +++ b/app/views/layouts/nav/_new_dashboard.html.haml @@ -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 diff --git a/app/views/layouts/nav/projects_dropdown/_show.html.haml b/app/views/layouts/nav/projects_dropdown/_show.html.haml new file mode 100644 index 00000000000..a7370180bf6 --- /dev/null +++ b/app/views/layouts/nav/projects_dropdown/_show.html.haml @@ -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 } } diff --git a/changelogs/unreleased/35010-projects-nav-dropdown.yml b/changelogs/unreleased/35010-projects-nav-dropdown.yml new file mode 100644 index 00000000000..c5bed723f55 --- /dev/null +++ b/changelogs/unreleased/35010-projects-nav-dropdown.yml @@ -0,0 +1,5 @@ +--- +title: Add dropdown to Projects nav item +merge_request: 13866 +author: +type: added diff --git a/config/webpack.config.js b/config/webpack.config.js index ad88e48550d..6b0cd023291 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -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', diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 2b7c6f7ad33..97bc3d80642 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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] "" diff --git a/spec/javascripts/api_spec.js b/spec/javascripts/api_spec.js index 8c68ceff914..2aa4fb1f6c6 100644 --- a/spec/javascripts/api_spec.js +++ b/spec/javascripts/api_spec.js @@ -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); diff --git a/spec/javascripts/project_title_spec.js b/spec/javascripts/project_title_spec.js index 3d36bb3e4d4..9ea8414ae40 100644 --- a/spec/javascripts/project_title_spec.js +++ b/spec/javascripts/project_title_spec.js @@ -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'); diff --git a/spec/javascripts/projects_dropdown/components/app_spec.js b/spec/javascripts/projects_dropdown/components/app_spec.js new file mode 100644 index 00000000000..42f0f6fc1af --- /dev/null +++ b/spec/javascripts/projects_dropdown/components/app_spec.js @@ -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(); + }); + }); + }); +}); diff --git a/spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js b/spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js new file mode 100644 index 00000000000..fcd0f6a3630 --- /dev/null +++ b/spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js @@ -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(); + }); + }); + }); +}); diff --git a/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js b/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js new file mode 100644 index 00000000000..171629fcd6b --- /dev/null +++ b/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js @@ -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); + }); + }); +}); diff --git a/spec/javascripts/projects_dropdown/components/projects_list_search_spec.js b/spec/javascripts/projects_dropdown/components/projects_list_search_spec.js new file mode 100644 index 00000000000..59fc2dedba5 --- /dev/null +++ b/spec/javascripts/projects_dropdown/components/projects_list_search_spec.js @@ -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(); + }); + }); + }); +}); diff --git a/spec/javascripts/projects_dropdown/components/search_spec.js b/spec/javascripts/projects_dropdown/components/search_spec.js new file mode 100644 index 00000000000..f2a23e33325 --- /dev/null +++ b/spec/javascripts/projects_dropdown/components/search_spec.js @@ -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(); + }); + }); +}); diff --git a/spec/javascripts/projects_dropdown/mock_data.js b/spec/javascripts/projects_dropdown/mock_data.js new file mode 100644 index 00000000000..d6a79fb8ac1 --- /dev/null +++ b/spec/javascripts/projects_dropdown/mock_data.js @@ -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 }, +]; diff --git a/spec/javascripts/projects_dropdown/service/projects_service_spec.js b/spec/javascripts/projects_dropdown/service/projects_service_spec.js new file mode 100644 index 00000000000..e9aabfaec1f --- /dev/null +++ b/spec/javascripts/projects_dropdown/service/projects_service_spec.js @@ -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); + }); + }); +}); diff --git a/spec/javascripts/projects_dropdown/store/projects_store_spec.js b/spec/javascripts/projects_dropdown/store/projects_store_spec.js new file mode 100644 index 00000000000..e57399d37cd --- /dev/null +++ b/spec/javascripts/projects_dropdown/store/projects_store_spec.js @@ -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); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/identicon_spec.js b/spec/javascripts/vue_shared/components/identicon_spec.js index 4f194e5a64e..647680f00f7 100644 --- a/spec/javascripts/vue_shared/components/identicon_spec.js +++ b/spec/javascripts/vue_shared/components/identicon_spec.js @@ -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; + describe('computed', () => { + let vm; - beforeEach(() => { - vm = createComponent(); - }); + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); - describe('computed', () => { 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(); }); }); }); |