Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Support
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
G
gitlab-ce
Project overview
Project overview
Details
Activity
Releases
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Issues
0
Issues
0
List
Boards
Labels
Milestones
Merge Requests
0
Merge Requests
0
Analytics
Analytics
Repository
Value Stream
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Create a new issue
Commits
Issue Boards
Open sidebar
Boxiang Sun
gitlab-ce
Commits
3892b022
Commit
3892b022
authored
Jul 06, 2018
by
Dennis Tang
Committed by
Phil Hughes
Jul 06, 2018
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Resolve "Add dropdown to Groups link in top bar"
parent
b14b31b8
Changes
45
Show whitespace changes
Inline
Side-by-side
Showing
45 changed files
with
1894 additions
and
1363 deletions
+1894
-1363
app/assets/javascripts/frequent_items/components/app.vue
app/assets/javascripts/frequent_items/components/app.vue
+122
-0
app/assets/javascripts/frequent_items/components/frequent_items_list.vue
...scripts/frequent_items/components/frequent_items_list.vue
+78
-0
app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
...ts/frequent_items/components/frequent_items_list_item.vue
+117
-0
app/assets/javascripts/frequent_items/components/frequent_items_mixin.js
...scripts/frequent_items/components/frequent_items_mixin.js
+23
-0
app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue
...frequent_items/components/frequent_items_search_input.vue
+55
-0
app/assets/javascripts/frequent_items/constants.js
app/assets/javascripts/frequent_items/constants.js
+38
-0
app/assets/javascripts/frequent_items/event_hub.js
app/assets/javascripts/frequent_items/event_hub.js
+0
-0
app/assets/javascripts/frequent_items/index.js
app/assets/javascripts/frequent_items/index.js
+69
-0
app/assets/javascripts/frequent_items/store/actions.js
app/assets/javascripts/frequent_items/store/actions.js
+81
-0
app/assets/javascripts/frequent_items/store/getters.js
app/assets/javascripts/frequent_items/store/getters.js
+4
-0
app/assets/javascripts/frequent_items/store/index.js
app/assets/javascripts/frequent_items/store/index.js
+16
-0
app/assets/javascripts/frequent_items/store/mutation_types.js
...assets/javascripts/frequent_items/store/mutation_types.js
+9
-0
app/assets/javascripts/frequent_items/store/mutations.js
app/assets/javascripts/frequent_items/store/mutations.js
+71
-0
app/assets/javascripts/frequent_items/store/state.js
app/assets/javascripts/frequent_items/store/state.js
+8
-0
app/assets/javascripts/frequent_items/utils.js
app/assets/javascripts/frequent_items/utils.js
+49
-0
app/assets/javascripts/main.js
app/assets/javascripts/main.js
+1
-1
app/assets/javascripts/projects_dropdown/components/app.vue
app/assets/javascripts/projects_dropdown/components/app.vue
+0
-158
app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue
...s/projects_dropdown/components/projects_list_frequent.vue
+0
-57
app/assets/javascripts/projects_dropdown/components/projects_list_item.vue
...ripts/projects_dropdown/components/projects_list_item.vue
+0
-116
app/assets/javascripts/projects_dropdown/components/search.vue
...ssets/javascripts/projects_dropdown/components/search.vue
+0
-65
app/assets/javascripts/projects_dropdown/constants.js
app/assets/javascripts/projects_dropdown/constants.js
+0
-10
app/assets/javascripts/projects_dropdown/index.js
app/assets/javascripts/projects_dropdown/index.js
+0
-66
app/assets/javascripts/projects_dropdown/service/projects_service.js
...javascripts/projects_dropdown/service/projects_service.js
+0
-137
app/assets/javascripts/projects_dropdown/store/projects_store.js
...ets/javascripts/projects_dropdown/store/projects_store.js
+0
-33
app/assets/stylesheets/framework/dropdowns.scss
app/assets/stylesheets/framework/dropdowns.scss
+39
-30
app/assets/stylesheets/framework/gitlab_theme.scss
app/assets/stylesheets/framework/gitlab_theme.scss
+13
-8
app/assets/stylesheets/framework/header.scss
app/assets/stylesheets/framework/header.scss
+15
-9
app/views/layouts/nav/_dashboard.html.haml
app/views/layouts/nav/_dashboard.html.haml
+7
-9
app/views/layouts/nav/groups_dropdown/_show.html.haml
app/views/layouts/nav/groups_dropdown/_show.html.haml
+12
-0
app/views/layouts/nav/projects_dropdown/_show.html.haml
app/views/layouts/nav/projects_dropdown/_show.html.haml
+3
-3
changelogs/unreleased/36234-nav-add-groups-dropdown.yml
changelogs/unreleased/36234-nav-add-groups-dropdown.yml
+5
-0
qa/qa/page/menu/main.rb
qa/qa/page/menu/main.rb
+8
-2
spec/javascripts/frequent_items/components/app_spec.js
spec/javascripts/frequent_items/components/app_spec.js
+251
-0
spec/javascripts/frequent_items/components/frequent_items_list_item_spec.js
...requent_items/components/frequent_items_list_item_spec.js
+16
-18
spec/javascripts/frequent_items/components/frequent_items_list_spec.js
...pts/frequent_items/components/frequent_items_list_spec.js
+84
-0
spec/javascripts/frequent_items/components/frequent_items_search_input_spec.js
...uent_items/components/frequent_items_search_input_spec.js
+77
-0
spec/javascripts/frequent_items/mock_data.js
spec/javascripts/frequent_items/mock_data.js
+168
-0
spec/javascripts/frequent_items/store/actions_spec.js
spec/javascripts/frequent_items/store/actions_spec.js
+225
-0
spec/javascripts/frequent_items/store/getters_spec.js
spec/javascripts/frequent_items/store/getters_spec.js
+24
-0
spec/javascripts/frequent_items/store/mutations_spec.js
spec/javascripts/frequent_items/store/mutations_spec.js
+117
-0
spec/javascripts/frequent_items/utils_spec.js
spec/javascripts/frequent_items/utils_spec.js
+89
-0
spec/javascripts/projects_dropdown/components/app_spec.js
spec/javascripts/projects_dropdown/components/app_spec.js
+0
-349
spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js
...ojects_dropdown/components/projects_list_frequent_spec.js
+0
-72
spec/javascripts/projects_dropdown/service/projects_service_spec.js
...cripts/projects_dropdown/service/projects_service_spec.js
+0
-179
spec/javascripts/projects_dropdown/store/projects_store_spec.js
...avascripts/projects_dropdown/store/projects_store_spec.js
+0
-41
No files found.
app/assets/javascripts/frequent_items/components/app.vue
0 → 100644
View file @
3892b022
<
script
>
import
{
mapState
,
mapActions
,
mapGetters
}
from
'
vuex
'
;
import
LoadingIcon
from
'
~/vue_shared/components/loading_icon.vue
'
;
import
AccessorUtilities
from
'
~/lib/utils/accessor
'
;
import
eventHub
from
'
../event_hub
'
;
import
store
from
'
../store/
'
;
import
{
FREQUENT_ITEMS
,
STORAGE_KEY
}
from
'
../constants
'
;
import
{
isMobile
,
updateExistingFrequentItem
}
from
'
../utils
'
;
import
FrequentItemsSearchInput
from
'
./frequent_items_search_input.vue
'
;
import
FrequentItemsList
from
'
./frequent_items_list.vue
'
;
import
frequentItemsMixin
from
'
./frequent_items_mixin
'
;
export
default
{
store
,
components
:
{
LoadingIcon
,
FrequentItemsSearchInput
,
FrequentItemsList
,
},
mixins
:
[
frequentItemsMixin
],
props
:
{
currentUserName
:
{
type
:
String
,
required
:
true
,
},
currentItem
:
{
type
:
Object
,
required
:
true
,
},
},
computed
:
{
...
mapState
([
'
searchQuery
'
,
'
isLoadingItems
'
,
'
isFetchFailed
'
,
'
items
'
]),
...
mapGetters
([
'
hasSearchQuery
'
]),
translations
()
{
return
this
.
getTranslations
([
'
loadingMessage
'
,
'
header
'
]);
},
},
created
()
{
const
{
namespace
,
currentUserName
,
currentItem
}
=
this
;
const
storageKey
=
`
${
currentUserName
}
/
${
STORAGE_KEY
[
namespace
]}
`
;
this
.
setNamespace
(
namespace
);
this
.
setStorageKey
(
storageKey
);
if
(
currentItem
.
id
)
{
this
.
logItemAccess
(
storageKey
,
currentItem
);
}
eventHub
.
$on
(
`
${
this
.
namespace
}
-dropdownOpen`
,
this
.
dropdownOpenHandler
);
},
beforeDestroy
()
{
eventHub
.
$off
(
`
${
this
.
namespace
}
-dropdownOpen`
,
this
.
dropdownOpenHandler
);
},
methods
:
{
...
mapActions
([
'
setNamespace
'
,
'
setStorageKey
'
,
'
fetchFrequentItems
'
]),
dropdownOpenHandler
()
{
if
(
this
.
searchQuery
===
''
||
isMobile
())
{
this
.
fetchFrequentItems
();
}
},
logItemAccess
(
storageKey
,
item
)
{
if
(
!
AccessorUtilities
.
isLocalStorageAccessSafe
())
{
return
false
;
}
// Check if there's any frequent items list set
const
storedRawItems
=
localStorage
.
getItem
(
storageKey
);
const
storedFrequentItems
=
storedRawItems
?
JSON
.
parse
(
storedRawItems
)
:
[{
...
item
,
frequency
:
1
}];
// No frequent items list set, set one up.
// Check if item already exists in list
const
itemMatchIndex
=
storedFrequentItems
.
findIndex
(
frequentItem
=>
frequentItem
.
id
===
item
.
id
,
);
if
(
itemMatchIndex
>
-
1
)
{
storedFrequentItems
[
itemMatchIndex
]
=
updateExistingFrequentItem
(
storedFrequentItems
[
itemMatchIndex
],
item
,
);
}
else
{
if
(
storedFrequentItems
.
length
===
FREQUENT_ITEMS
.
MAX_COUNT
)
{
storedFrequentItems
.
shift
();
}
storedFrequentItems
.
push
({
...
item
,
frequency
:
1
});
}
return
localStorage
.
setItem
(
storageKey
,
JSON
.
stringify
(
storedFrequentItems
));
},
},
};
</
script
>
<
template
>
<div>
<frequent-items-search-input
:namespace=
"namespace"
/>
<loading-icon
v-if=
"isLoadingItems"
:label=
"translations.loadingMessage"
class=
"loading-animation prepend-top-20"
size=
"2"
/>
<div
v-if=
"!isLoadingItems && !hasSearchQuery"
class=
"section-header"
>
{{
translations
.
header
}}
</div>
<frequent-items-list
v-if=
"!isLoadingItems"
:items=
"items"
:namespace=
"namespace"
:has-search-query=
"hasSearchQuery"
:is-fetch-failed=
"isFetchFailed"
:matcher=
"searchQuery"
/>
</div>
</
template
>
app/assets/javascripts/
projects_dropdown/components/projects_list_search
.vue
→
app/assets/javascripts/
frequent_items/components/frequent_items_list
.vue
View file @
3892b022
<
script
>
import
{
s__
}
from
'
../../local
e
'
;
import
projectsListItem
from
'
./projects_list_item.vue
'
;
import
FrequentItemsListItem
from
'
./frequent_items_list_item.vu
e
'
;
import
frequentItemsMixin
from
'
./frequent_items_mixin
'
;
export
default
{
components
:
{
project
sListItem
,
FrequentItem
sListItem
,
},
mixins
:
[
frequentItemsMixin
],
props
:
{
matcher
:
{
type
:
String
,
items
:
{
type
:
Array
,
required
:
true
,
},
projects
:
{
type
:
Array
,
hasSearchQuery
:
{
type
:
Boolean
,
required
:
true
,
},
sear
chFailed
:
{
isFet
chFailed
:
{
type
:
Boolean
,
required
:
true
,
},
matcher
:
{
type
:
String
,
required
:
true
,
},
},
computed
:
{
translations
()
{
return
this
.
getTranslations
([
'
itemListEmptyMessage
'
,
'
itemListErrorMessage
'
,
'
searchListEmptyMessage
'
,
'
searchListErrorMessage
'
,
]);
},
isListEmpty
()
{
return
this
.
project
s
.
length
===
0
;
return
this
.
item
s
.
length
===
0
;
},
listEmptyMessage
()
{
return
this
.
searchFailed
?
s__
(
'
ProjectsDropdown|Something went wrong on our end.
'
)
:
s__
(
'
ProjectsDropdown|Sorry, no projects matched your search
'
);
if
(
this
.
hasSearchQuery
)
{
return
this
.
isFetchFailed
?
this
.
translations
.
searchListErrorMessage
:
this
.
translations
.
searchListEmptyMessage
;
}
return
this
.
isFetchFailed
?
this
.
translations
.
itemListErrorMessage
:
this
.
translations
.
itemListEmptyMessage
;
},
},
};
</
script
>
<
template
>
<div
class=
"projects-list-search-container"
>
<ul
class=
"list-unstyled"
>
<div
class=
"frequent-items-list-container"
>
<ul
class=
"list-unstyled"
>
<li
v-if=
"isListEmpty"
:class=
"
{ 'section-failure':
sear
chFailed }"
:class=
"
{ 'section-failure':
isFet
chFailed }"
class="section-empty"
>
{{
listEmptyMessage
}}
</li>
<
project
s-list-item
v-for=
"
(project, index) in project
s"
<
frequent-item
s-list-item
v-for=
"
item in item
s"
v-else
:key=
"i
ndex
"
:
project-id=
"project
.id"
:
project-name=
"project
.name"
:namespace=
"
project
.namespace"
:web-url=
"
project
.webUrl"
:avatar-url=
"
project
.avatarUrl"
:key=
"i
tem.id
"
:
item-id=
"item
.id"
:
item-name=
"item
.name"
:namespace=
"
item
.namespace"
:web-url=
"
item
.webUrl"
:avatar-url=
"
item
.avatarUrl"
:matcher=
"matcher"
/>
</ul>
...
...
app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
0 → 100644
View file @
3892b022
<
script
>
/* eslint-disable vue/require-default-prop, vue/require-prop-types */
import
Identicon
from
'
../../vue_shared/components/identicon.vue
'
;
export
default
{
components
:
{
Identicon
,
},
props
:
{
matcher
:
{
type
:
String
,
required
:
false
,
},
itemId
:
{
type
:
Number
,
required
:
true
,
},
itemName
:
{
type
:
String
,
required
:
true
,
},
namespace
:
{
type
:
String
,
required
:
false
,
},
webUrl
:
{
type
:
String
,
required
:
true
,
},
avatarUrl
:
{
required
:
true
,
validator
(
value
)
{
return
value
===
null
||
typeof
value
===
'
string
'
;
},
},
},
computed
:
{
hasAvatar
()
{
return
this
.
avatarUrl
!==
null
;
},
highlightedItemName
()
{
if
(
this
.
matcher
)
{
const
matcherRegEx
=
new
RegExp
(
this
.
matcher
,
'
gi
'
);
const
matches
=
this
.
itemName
.
match
(
matcherRegEx
);
if
(
matches
&&
matches
.
length
>
0
)
{
return
this
.
itemName
.
replace
(
matches
[
0
],
`<b>
${
matches
[
0
]}
</b>`
);
}
}
return
this
.
itemName
;
},
/**
* Smartly truncates item namespace by doing two things;
* 1. Only include Group names in path by removing item name
* 2. Only include first and last group names in the path
* when namespace has more than 2 groups present
*
* First part (removal of item name from namespace) can be
* done from backend but doing so involves migration of
* existing item namespaces which is not wise thing to do.
*/
truncatedNamespace
()
{
if
(
!
this
.
namespace
)
{
return
null
;
}
const
namespaceArr
=
this
.
namespace
.
split
(
'
/
'
);
namespaceArr
.
splice
(
-
1
,
1
);
let
namespace
=
namespaceArr
.
join
(
'
/
'
);
if
(
namespaceArr
.
length
>
2
)
{
namespace
=
`
${
namespaceArr
[
0
]}
/ ... /
${
namespaceArr
.
pop
()}
`
;
}
return
namespace
;
},
},
};
</
script
>
<
template
>
<li
class=
"frequent-items-list-item-container"
>
<a
:href=
"webUrl"
class=
"clearfix"
>
<div
class=
"frequent-items-item-avatar-container"
>
<img
v-if=
"hasAvatar"
:src=
"avatarUrl"
class=
"avatar s32"
/>
<identicon
v-else
:entity-id=
"itemId"
:entity-name=
"itemName"
size-class=
"s32"
/>
</div>
<div
class=
"frequent-items-item-metadata-container"
>
<div
:title=
"itemName"
class=
"frequent-items-item-title"
v-html=
"highlightedItemName"
>
</div>
<div
v-if=
"truncatedNamespace"
:title=
"namespace"
class=
"frequent-items-item-namespace"
>
{{
truncatedNamespace
}}
</div>
</div>
</a>
</li>
</
template
>
app/assets/javascripts/frequent_items/components/frequent_items_mixin.js
0 → 100644
View file @
3892b022
import
{
TRANSLATION_KEYS
}
from
'
../constants
'
;
export
default
{
props
:
{
namespace
:
{
type
:
String
,
required
:
true
,
},
},
methods
:
{
getTranslations
(
keys
)
{
const
translationStrings
=
keys
.
reduce
(
(
acc
,
key
)
=>
({
...
acc
,
[
key
]:
TRANSLATION_KEYS
[
this
.
namespace
][
key
],
}),
{},
);
return
translationStrings
;
},
},
};
app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue
0 → 100644
View file @
3892b022
<
script
>
import
_
from
'
underscore
'
;
import
{
mapActions
}
from
'
vuex
'
;
import
eventHub
from
'
../event_hub
'
;
import
frequentItemsMixin
from
'
./frequent_items_mixin
'
;
export
default
{
mixins
:
[
frequentItemsMixin
],
data
()
{
return
{
searchQuery
:
''
,
};
},
computed
:
{
translations
()
{
return
this
.
getTranslations
([
'
searchInputPlaceholder
'
]);
},
},
watch
:
{
searchQuery
:
_
.
debounce
(
function
debounceSearchQuery
()
{
this
.
setSearchQuery
(
this
.
searchQuery
);
},
500
),
},
mounted
()
{
eventHub
.
$on
(
`
${
this
.
namespace
}
-dropdownOpen`
,
this
.
setFocus
);
},
beforeDestroy
()
{
eventHub
.
$off
(
`
${
this
.
namespace
}
-dropdownOpen`
,
this
.
setFocus
);
},
methods
:
{
...
mapActions
([
'
setSearchQuery
'
]),
setFocus
()
{
this
.
$refs
.
search
.
focus
();
},
},
};
</
script
>
<
template
>
<div
class=
"search-input-container d-none d-sm-block"
>
<input
ref=
"search"
v-model=
"searchQuery"
:placeholder=
"translations.searchInputPlaceholder"
type=
"search"
class=
"form-control"
/>
<i
v-if=
"!searchQuery"
class=
"search-icon fa fa-fw fa-search"
aria-hidden=
"true"
>
</i>
</div>
</
template
>
app/assets/javascripts/frequent_items/constants.js
0 → 100644
View file @
3892b022
import
{
s__
}
from
'
~/locale
'
;
export
const
FREQUENT_ITEMS
=
{
MAX_COUNT
:
20
,
LIST_COUNT_DESKTOP
:
5
,
LIST_COUNT_MOBILE
:
3
,
ELIGIBLE_FREQUENCY
:
3
,
};
export
const
HOUR_IN_MS
=
3600000
;
export
const
STORAGE_KEY
=
{
projects
:
'
frequent-projects
'
,
groups
:
'
frequent-groups
'
,
};
export
const
TRANSLATION_KEYS
=
{
projects
:
{
loadingMessage
:
s__
(
'
ProjectsDropdown|Loading projects
'
),
header
:
s__
(
'
ProjectsDropdown|Frequently visited
'
),
itemListErrorMessage
:
s__
(
'
ProjectsDropdown|This feature requires browser localStorage support
'
,
),
itemListEmptyMessage
:
s__
(
'
ProjectsDropdown|Projects you visit often will appear here
'
),
searchListErrorMessage
:
s__
(
'
ProjectsDropdown|Something went wrong on our end.
'
),
searchListEmptyMessage
:
s__
(
'
ProjectsDropdown|Sorry, no projects matched your search
'
),
searchInputPlaceholder
:
s__
(
'
ProjectsDropdown|Search your projects
'
),
},
groups
:
{
loadingMessage
:
s__
(
'
GroupsDropdown|Loading groups
'
),
header
:
s__
(
'
GroupsDropdown|Frequently visited
'
),
itemListErrorMessage
:
s__
(
'
GroupsDropdown|This feature requires browser localStorage support
'
),
itemListEmptyMessage
:
s__
(
'
GroupsDropdown|Groups you visit often will appear here
'
),
searchListErrorMessage
:
s__
(
'
GroupsDropdown|Something went wrong on our end.
'
),
searchListEmptyMessage
:
s__
(
'
GroupsDropdown|Sorry, no groups matched your search
'
),
searchInputPlaceholder
:
s__
(
'
GroupsDropdown|Search your groups
'
),
},
};
app/assets/javascripts/
projects_dropdown
/event_hub.js
→
app/assets/javascripts/
frequent_items
/event_hub.js
View file @
3892b022
File moved
app/assets/javascripts/frequent_items/index.js
0 → 100644
View file @
3892b022
import
$
from
'
jquery
'
;
import
Vue
from
'
vue
'
;
import
Translate
from
'
~/vue_shared/translate
'
;
import
eventHub
from
'
~/frequent_items/event_hub
'
;
import
frequentItems
from
'
./components/app.vue
'
;
Vue
.
use
(
Translate
);
const
frequentItemDropdowns
=
[
{
namespace
:
'
projects
'
,
key
:
'
project
'
,
},
{
namespace
:
'
groups
'
,
key
:
'
group
'
,
},
];
document
.
addEventListener
(
'
DOMContentLoaded
'
,
()
=>
{
frequentItemDropdowns
.
forEach
(
dropdown
=>
{
const
{
namespace
,
key
}
=
dropdown
;
const
el
=
document
.
getElementById
(
`js-
${
namespace
}
-dropdown`
);
const
navEl
=
document
.
getElementById
(
`nav-
${
namespace
}
-dropdown`
);
// Don't do anything if element doesn't exist (No groups dropdown)
// This is for when the user accesses GitLab without logging in
if
(
!
el
||
!
navEl
)
{
return
;
}
$
(
navEl
).
on
(
'
shown.bs.dropdown
'
,
()
=>
{
eventHub
.
$emit
(
`
${
namespace
}
-dropdownOpen`
);
});
// eslint-disable-next-line no-new
new
Vue
({
el
,
components
:
{
frequentItems
,
},
data
()
{
const
{
dataset
}
=
this
.
$options
.
el
;
const
item
=
{
id
:
Number
(
dataset
[
`
${
key
}
Id`
]),
name
:
dataset
[
`
${
key
}
Name`
],
namespace
:
dataset
[
`
${
key
}
Namespace`
],
webUrl
:
dataset
[
`
${
key
}
WebUrl`
],
avatarUrl
:
dataset
[
`
${
key
}
AvatarUrl`
]
||
null
,
lastAccessedOn
:
Date
.
now
(),
};
return
{
currentUserName
:
dataset
.
userName
,
currentItem
:
item
,
};
},
render
(
createElement
)
{
return
createElement
(
'
frequent-items
'
,
{
props
:
{
namespace
,
currentUserName
:
this
.
currentUserName
,
currentItem
:
this
.
currentItem
,
},
});
},
});
});
});
app/assets/javascripts/frequent_items/store/actions.js
0 → 100644
View file @
3892b022
import
Api
from
'
~/api
'
;
import
AccessorUtilities
from
'
~/lib/utils/accessor
'
;
import
*
as
types
from
'
./mutation_types
'
;
import
{
getTopFrequentItems
}
from
'
../utils
'
;
export
const
setNamespace
=
({
commit
},
namespace
)
=>
{
commit
(
types
.
SET_NAMESPACE
,
namespace
);
};
export
const
setStorageKey
=
({
commit
},
key
)
=>
{
commit
(
types
.
SET_STORAGE_KEY
,
key
);
};
export
const
requestFrequentItems
=
({
commit
})
=>
{
commit
(
types
.
REQUEST_FREQUENT_ITEMS
);
};
export
const
receiveFrequentItemsSuccess
=
({
commit
},
data
)
=>
{
commit
(
types
.
RECEIVE_FREQUENT_ITEMS_SUCCESS
,
data
);
};
export
const
receiveFrequentItemsError
=
({
commit
})
=>
{
commit
(
types
.
RECEIVE_FREQUENT_ITEMS_ERROR
);
};
export
const
fetchFrequentItems
=
({
state
,
dispatch
})
=>
{
dispatch
(
'
requestFrequentItems
'
);
if
(
AccessorUtilities
.
isLocalStorageAccessSafe
())
{
const
storedFrequentItems
=
JSON
.
parse
(
localStorage
.
getItem
(
state
.
storageKey
));
dispatch
(
'
receiveFrequentItemsSuccess
'
,
!
storedFrequentItems
?
[]
:
getTopFrequentItems
(
storedFrequentItems
),
);
}
else
{
dispatch
(
'
receiveFrequentItemsError
'
);
}
};
export
const
requestSearchedItems
=
({
commit
})
=>
{
commit
(
types
.
REQUEST_SEARCHED_ITEMS
);
};
export
const
receiveSearchedItemsSuccess
=
({
commit
},
data
)
=>
{
commit
(
types
.
RECEIVE_SEARCHED_ITEMS_SUCCESS
,
data
);
};
export
const
receiveSearchedItemsError
=
({
commit
})
=>
{
commit
(
types
.
RECEIVE_SEARCHED_ITEMS_ERROR
);
};
export
const
fetchSearchedItems
=
({
state
,
dispatch
},
searchQuery
)
=>
{
dispatch
(
'
requestSearchedItems
'
);
const
params
=
{
simple
:
true
,
per_page
:
20
,
membership
:
!!
gon
.
current_user_id
,
};
if
(
state
.
namespace
===
'
projects
'
)
{
params
.
order_by
=
'
last_activity_at
'
;
}
return
Api
[
state
.
namespace
](
searchQuery
,
params
)
.
then
(
results
=>
{
dispatch
(
'
receiveSearchedItemsSuccess
'
,
results
);
})
.
catch
(()
=>
{
dispatch
(
'
receiveSearchedItemsError
'
);
});
};
export
const
setSearchQuery
=
({
commit
,
dispatch
},
query
)
=>
{
commit
(
types
.
SET_SEARCH_QUERY
,
query
);
if
(
query
)
{
dispatch
(
'
fetchSearchedItems
'
,
query
);
}
else
{
dispatch
(
'
fetchFrequentItems
'
);
}
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export
default
()
=>
{};
app/assets/javascripts/frequent_items/store/getters.js
0 → 100644
View file @
3892b022
export
const
hasSearchQuery
=
state
=>
state
.
searchQuery
!==
''
;
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export
default
()
=>
{};
app/assets/javascripts/frequent_items/store/index.js
0 → 100644
View file @
3892b022
import
Vue
from
'
vue
'
;
import
Vuex
from
'
vuex
'
;
import
*
as
actions
from
'
./actions
'
;
import
*
as
getters
from
'
./getters
'
;
import
mutations
from
'
./mutations
'
;
import
state
from
'
./state
'
;
Vue
.
use
(
Vuex
);
export
default
()
=>
new
Vuex
.
Store
({
actions
,
getters
,
mutations
,
state
:
state
(),
});
app/assets/javascripts/frequent_items/store/mutation_types.js
0 → 100644
View file @
3892b022
export
const
SET_NAMESPACE
=
'
SET_NAMESPACE
'
;
export
const
SET_STORAGE_KEY
=
'
SET_STORAGE_KEY
'
;
export
const
SET_SEARCH_QUERY
=
'
SET_SEARCH_QUERY
'
;
export
const
REQUEST_FREQUENT_ITEMS
=
'
REQUEST_FREQUENT_ITEMS
'
;
export
const
RECEIVE_FREQUENT_ITEMS_SUCCESS
=
'
RECEIVE_FREQUENT_ITEMS_SUCCESS
'
;
export
const
RECEIVE_FREQUENT_ITEMS_ERROR
=
'
RECEIVE_FREQUENT_ITEMS_ERROR
'
;
export
const
REQUEST_SEARCHED_ITEMS
=
'
REQUEST_SEARCHED_ITEMS
'
;
export
const
RECEIVE_SEARCHED_ITEMS_SUCCESS
=
'
RECEIVE_SEARCHED_ITEMS_SUCCESS
'
;
export
const
RECEIVE_SEARCHED_ITEMS_ERROR
=
'
RECEIVE_SEARCHED_ITEMS_ERROR
'
;
app/assets/javascripts/frequent_items/store/mutations.js
0 → 100644
View file @
3892b022
import
*
as
types
from
'
./mutation_types
'
;
export
default
{
[
types
.
SET_NAMESPACE
](
state
,
namespace
)
{
Object
.
assign
(
state
,
{
namespace
,
});
},
[
types
.
SET_STORAGE_KEY
](
state
,
storageKey
)
{
Object
.
assign
(
state
,
{
storageKey
,
});
},
[
types
.
SET_SEARCH_QUERY
](
state
,
searchQuery
)
{
const
hasSearchQuery
=
searchQuery
!==
''
;
Object
.
assign
(
state
,
{
searchQuery
,
isLoadingItems
:
true
,
hasSearchQuery
,
});
},
[
types
.
REQUEST_FREQUENT_ITEMS
](
state
)
{
Object
.
assign
(
state
,
{
isLoadingItems
:
true
,
hasSearchQuery
:
false
,
});
},
[
types
.
RECEIVE_FREQUENT_ITEMS_SUCCESS
](
state
,
rawItems
)
{
Object
.
assign
(
state
,
{
items
:
rawItems
,
isLoadingItems
:
false
,
hasSearchQuery
:
false
,
isFetchFailed
:
false
,
});
},
[
types
.
RECEIVE_FREQUENT_ITEMS_ERROR
](
state
)
{
Object
.
assign
(
state
,
{
isLoadingItems
:
false
,
hasSearchQuery
:
false
,
isFetchFailed
:
true
,
});
},
[
types
.
REQUEST_SEARCHED_ITEMS
](
state
)
{
Object
.
assign
(
state
,
{
isLoadingItems
:
true
,
hasSearchQuery
:
true
,
});
},
[
types
.
RECEIVE_SEARCHED_ITEMS_SUCCESS
](
state
,
rawItems
)
{
Object
.
assign
(
state
,
{
items
:
rawItems
.
map
(
rawItem
=>
({
id
:
rawItem
.
id
,
name
:
rawItem
.
name
,
namespace
:
rawItem
.
name_with_namespace
||
rawItem
.
full_name
,
webUrl
:
rawItem
.
web_url
,
avatarUrl
:
rawItem
.
avatar_url
,
})),
isLoadingItems
:
false
,
hasSearchQuery
:
true
,
isFetchFailed
:
false
,
});
},
[
types
.
RECEIVE_SEARCHED_ITEMS_ERROR
](
state
)
{
Object
.
assign
(
state
,
{
isLoadingItems
:
false
,
hasSearchQuery
:
true
,
isFetchFailed
:
true
,
});
},
};
app/assets/javascripts/frequent_items/store/state.js
0 → 100644
View file @
3892b022
export
default
()
=>
({
namespace
:
''
,
storageKey
:
''
,
searchQuery
:
''
,
isLoadingItems
:
false
,
isFetchFailed
:
false
,
items
:
[],
});
app/assets/javascripts/frequent_items/utils.js
0 → 100644
View file @
3892b022
import
_
from
'
underscore
'
;
import
bp
from
'
~/breakpoints
'
;
import
{
FREQUENT_ITEMS
,
HOUR_IN_MS
}
from
'
./constants
'
;
export
const
isMobile
=
()
=>
{
const
screenSize
=
bp
.
getBreakpointSize
();
return
screenSize
===
'
sm
'
||
screenSize
===
'
xs
'
;
};
export
const
getTopFrequentItems
=
items
=>
{
if
(
!
items
)
{
return
[];
}
const
frequentItemsCount
=
isMobile
()
?
FREQUENT_ITEMS
.
LIST_COUNT_MOBILE
:
FREQUENT_ITEMS
.
LIST_COUNT_DESKTOP
;
const
frequentItems
=
items
.
filter
(
item
=>
item
.
frequency
>=
FREQUENT_ITEMS
.
ELIGIBLE_FREQUENCY
);
if
(
!
frequentItems
||
frequentItems
.
length
===
0
)
{
return
[];
}
frequentItems
.
sort
((
itemA
,
itemB
)
=>
{
// Sort all frequent items in decending order of frequency
// and then by lastAccessedOn with recent most first
if
(
itemA
.
frequency
!==
itemB
.
frequency
)
{
return
itemB
.
frequency
-
itemA
.
frequency
;
}
else
if
(
itemA
.
lastAccessedOn
!==
itemB
.
lastAccessedOn
)
{
return
itemB
.
lastAccessedOn
-
itemA
.
lastAccessedOn
;
}
return
0
;
});
return
_
.
first
(
frequentItems
,
frequentItemsCount
);
};
export
const
updateExistingFrequentItem
=
(
frequentItem
,
item
)
=>
{
const
accessedOverHourAgo
=
Math
.
abs
(
item
.
lastAccessedOn
-
frequentItem
.
lastAccessedOn
)
/
HOUR_IN_MS
>
1
;
return
{
...
item
,
frequency
:
accessedOverHourAgo
?
frequentItem
.
frequency
+
1
:
frequentItem
.
frequency
,
lastAccessedOn
:
accessedOverHourAgo
?
Date
.
now
()
:
frequentItem
.
lastAccessedOn
,
};
};
app/assets/javascripts/main.js
View file @
3892b022
...
...
@@ -26,7 +26,7 @@ import './feature_highlight/feature_highlight_options';
import
LazyLoader
from
'
./lazy_loader
'
;
import
initLogoAnimation
from
'
./logo
'
;
import
'
./milestone_select
'
;
import
'
./
projects_dropdown
'
;
import
'
./
frequent_items
'
;
import
initBreadcrumbs
from
'
./breadcrumb
'
;
import
initDispatcher
from
'
./dispatcher
'
;
...
...
app/assets/javascripts/projects_dropdown/components/app.vue
deleted
100644 → 0
View file @
b14b31b8
<
script
>
import
bs
from
'
../../breakpoints
'
;
import
eventHub
from
'
../event_hub
'
;
import
loadingIcon
from
'
../../vue_shared/components/loading_icon.vue
'
;
import
projectsListFrequent
from
'
./projects_list_frequent.vue
'
;
import
projectsListSearch
from
'
./projects_list_search.vue
'
;
import
search
from
'
./search.vue
'
;
export
default
{
components
:
{
search
,
loadingIcon
,
projectsListFrequent
,
projectsListSearch
,
},
props
:
{
currentProject
:
{
type
:
Object
,
required
:
true
,
},
store
:
{
type
:
Object
,
required
:
true
,
},
service
:
{
type
:
Object
,
required
:
true
,
},
},
data
()
{
return
{
isLoadingProjects
:
false
,
isFrequentsListVisible
:
false
,
isSearchListVisible
:
false
,
isLocalStorageFailed
:
false
,
isSearchFailed
:
false
,
searchQuery
:
''
,
};
},
computed
:
{
frequentProjects
()
{
return
this
.
store
.
getFrequentProjects
();
},
searchProjects
()
{
return
this
.
store
.
getSearchedProjects
();
},
},
created
()
{
if
(
this
.
currentProject
.
id
)
{
this
.
logCurrentProjectAccess
();
}
eventHub
.
$on
(
'
dropdownOpen
'
,
this
.
fetchFrequentProjects
);
eventHub
.
$on
(
'
searchProjects
'
,
this
.
fetchSearchedProjects
);
eventHub
.
$on
(
'
searchCleared
'
,
this
.
handleSearchClear
);
eventHub
.
$on
(
'
searchFailed
'
,
this
.
handleSearchFailure
);
},
beforeDestroy
()
{
eventHub
.
$off
(
'
dropdownOpen
'
,
this
.
fetchFrequentProjects
);
eventHub
.
$off
(
'
searchProjects
'
,
this
.
fetchSearchedProjects
);
eventHub
.
$off
(
'
searchCleared
'
,
this
.
handleSearchClear
);
eventHub
.
$off
(
'
searchFailed
'
,
this
.
handleSearchFailure
);
},
methods
:
{
toggleFrequentProjectsList
(
state
)
{
this
.
isLoadingProjects
=
!
state
;
this
.
isSearchListVisible
=
!
state
;
this
.
isFrequentsListVisible
=
state
;
},
toggleSearchProjectsList
(
state
)
{
this
.
isLoadingProjects
=
!
state
;
this
.
isFrequentsListVisible
=
!
state
;
this
.
isSearchListVisible
=
state
;
},
toggleLoader
(
state
)
{
this
.
isFrequentsListVisible
=
!
state
;
this
.
isSearchListVisible
=
!
state
;
this
.
isLoadingProjects
=
state
;
},
fetchFrequentProjects
()
{
const
screenSize
=
bs
.
getBreakpointSize
();
if
(
this
.
searchQuery
&&
(
screenSize
!==
'
sm
'
&&
screenSize
!==
'
xs
'
))
{
this
.
toggleSearchProjectsList
(
true
);
}
else
{
this
.
toggleLoader
(
true
);
this
.
isLocalStorageFailed
=
false
;
const
projects
=
this
.
service
.
getFrequentProjects
();
if
(
projects
)
{
this
.
toggleFrequentProjectsList
(
true
);
this
.
store
.
setFrequentProjects
(
projects
);
}
else
{
this
.
isLocalStorageFailed
=
true
;
this
.
toggleFrequentProjectsList
(
true
);
this
.
store
.
setFrequentProjects
([]);
}
}
},
fetchSearchedProjects
(
searchQuery
)
{
this
.
searchQuery
=
searchQuery
;
this
.
toggleLoader
(
true
);
this
.
service
.
getSearchedProjects
(
this
.
searchQuery
)
.
then
(
res
=>
res
.
json
())
.
then
(
results
=>
{
this
.
toggleSearchProjectsList
(
true
);
this
.
store
.
setSearchedProjects
(
results
);
})
.
catch
(()
=>
{
this
.
isSearchFailed
=
true
;
this
.
toggleSearchProjectsList
(
true
);
});
},
logCurrentProjectAccess
()
{
this
.
service
.
logProjectAccess
(
this
.
currentProject
);
},
handleSearchClear
()
{
this
.
searchQuery
=
''
;
this
.
toggleFrequentProjectsList
(
true
);
this
.
store
.
clearSearchedProjects
();
},
handleSearchFailure
()
{
this
.
isSearchFailed
=
true
;
this
.
toggleSearchProjectsList
(
true
);
},
},
};
</
script
>
<
template
>
<div>
<search/>
<loading-icon
v-if=
"isLoadingProjects"
:label=
"s__('ProjectsDropdown|Loading projects')"
class=
"loading-animation prepend-top-20"
size=
"2"
/>
<div
v-if=
"isFrequentsListVisible"
class=
"section-header"
>
{{
s__
(
'
ProjectsDropdown|Frequently visited
'
)
}}
</div>
<projects-list-frequent
v-if=
"isFrequentsListVisible"
:local-storage-failed=
"isLocalStorageFailed"
:projects=
"frequentProjects"
/>
<projects-list-search
v-if=
"isSearchListVisible"
:search-failed=
"isSearchFailed"
:matcher=
"searchQuery"
:projects=
"searchProjects"
/>
</div>
</
template
>
app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue
deleted
100644 → 0
View file @
b14b31b8
<
script
>
import
{
s__
}
from
'
../../locale
'
;
import
projectsListItem
from
'
./projects_list_item.vue
'
;
export
default
{
components
:
{
projectsListItem
,
},
props
:
{
projects
:
{
type
:
Array
,
required
:
true
,
},
localStorageFailed
:
{
type
:
Boolean
,
required
:
true
,
},
},
computed
:
{
isListEmpty
()
{
return
this
.
projects
.
length
===
0
;
},
listEmptyMessage
()
{
return
this
.
localStorageFailed
?
s__
(
'
ProjectsDropdown|This feature requires browser localStorage support
'
)
:
s__
(
'
ProjectsDropdown|Projects you visit often will appear here
'
);
},
},
};
</
script
>
<
template
>
<div
class=
"projects-list-frequent-container"
>
<ul
class=
"list-unstyled"
>
<li
v-if=
"isListEmpty"
class=
"section-empty"
>
{{
listEmptyMessage
}}
</li>
<projects-list-item
v-for=
"(project, index) in projects"
v-else
:key=
"index"
:project-id=
"project.id"
:project-name=
"project.name"
:namespace=
"project.namespace"
:web-url=
"project.webUrl"
:avatar-url=
"project.avatarUrl"
/>
</ul>
</div>
</
template
>
app/assets/javascripts/projects_dropdown/components/projects_list_item.vue
deleted
100644 → 0
View file @
b14b31b8
<
script
>
/* eslint-disable vue/require-default-prop, vue/require-prop-types */
import
identicon
from
'
../../vue_shared/components/identicon.vue
'
;
export
default
{
components
:
{
identicon
,
},
props
:
{
matcher
:
{
type
:
String
,
required
:
false
,
},
projectId
:
{
type
:
Number
,
required
:
true
,
},
projectName
:
{
type
:
String
,
required
:
true
,
},
namespace
:
{
type
:
String
,
required
:
true
,
},
webUrl
:
{
type
:
String
,
required
:
true
,
},
avatarUrl
:
{
required
:
true
,
validator
(
value
)
{
return
value
===
null
||
typeof
value
===
'
string
'
;
},
},
},
computed
:
{
hasAvatar
()
{
return
this
.
avatarUrl
!==
null
;
},
highlightedProjectName
()
{
if
(
this
.
matcher
)
{
const
matcherRegEx
=
new
RegExp
(
this
.
matcher
,
'
gi
'
);
const
matches
=
this
.
projectName
.
match
(
matcherRegEx
);
if
(
matches
&&
matches
.
length
>
0
)
{
return
this
.
projectName
.
replace
(
matches
[
0
],
`<b>
${
matches
[
0
]}
</b>`
);
}
}
return
this
.
projectName
;
},
/**
* Smartly truncates project namespace by doing two things;
* 1. Only include Group names in path by removing project name
* 2. Only include first and last group names in the path
* when namespace has more than 2 groups present
*
* First part (removal of project name from namespace) can be
* done from backend but doing so involves migration of
* existing project namespaces which is not wise thing to do.
*/
truncatedNamespace
()
{
const
namespaceArr
=
this
.
namespace
.
split
(
'
/
'
);
namespaceArr
.
splice
(
-
1
,
1
);
let
namespace
=
namespaceArr
.
join
(
'
/
'
);
if
(
namespaceArr
.
length
>
2
)
{
namespace
=
`
${
namespaceArr
[
0
]}
/ ... /
${
namespaceArr
.
pop
()}
`
;
}
return
namespace
;
},
},
};
</
script
>
<
template
>
<li
class=
"projects-list-item-container"
>
<a
:href=
"webUrl"
class=
"clearfix"
>
<div
class=
"project-item-avatar-container"
>
<img
v-if=
"hasAvatar"
:src=
"avatarUrl"
class=
"avatar s32"
/>
<identicon
v-else
:entity-id=
"projectId"
:entity-name=
"projectName"
size-class=
"s32"
/>
</div>
<div
class=
"project-item-metadata-container"
>
<div
:title=
"projectName"
class=
"project-title"
v-html=
"highlightedProjectName"
>
</div>
<div
:title=
"namespace"
class=
"project-namespace"
>
{{
truncatedNamespace
}}
</div>
</div>
</a>
</li>
</
template
>
app/assets/javascripts/projects_dropdown/components/search.vue
deleted
100644 → 0
View file @
b14b31b8
<
script
>
import
_
from
'
underscore
'
;
import
eventHub
from
'
../event_hub
'
;
export
default
{
data
()
{
return
{
searchQuery
:
''
,
};
},
watch
:
{
searchQuery
()
{
this
.
handleInput
();
},
},
mounted
()
{
eventHub
.
$on
(
'
dropdownOpen
'
,
this
.
setFocus
);
},
beforeDestroy
()
{
eventHub
.
$off
(
'
dropdownOpen
'
,
this
.
setFocus
);
},
methods
:
{
setFocus
()
{
this
.
$refs
.
search
.
focus
();
},
emitSearchEvents
()
{
if
(
this
.
searchQuery
)
{
eventHub
.
$emit
(
'
searchProjects
'
,
this
.
searchQuery
);
}
else
{
eventHub
.
$emit
(
'
searchCleared
'
);
}
},
/**
* Callback function within _.debounce is intentionally
* kept as ES5 `function() {}` instead of ES6 `() => {}`
* as it otherwise messes up function context
* and component reference is no longer accessible via `this`
*/
// eslint-disable-next-line func-names
handleInput
:
_
.
debounce
(
function
()
{
this
.
emitSearchEvents
();
},
500
),
},
};
</
script
>
<
template
>
<div
class=
"search-input-container d-none d-sm-block"
>
<input
ref=
"search"
v-model=
"searchQuery"
:placeholder=
"s__('ProjectsDropdown|Search your projects')"
type=
"search"
class=
"form-control"
/>
<i
v-if=
"!searchQuery"
class=
"search-icon fa fa-fw fa-search"
aria-hidden=
"true"
>
</i>
</div>
</
template
>
app/assets/javascripts/projects_dropdown/constants.js
deleted
100644 → 0
View file @
b14b31b8
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
'
;
app/assets/javascripts/projects_dropdown/index.js
deleted
100644 → 0
View file @
b14b31b8
import
$
from
'
jquery
'
;
import
Vue
from
'
vue
'
;
import
Translate
from
'
../vue_shared/translate
'
;
import
eventHub
from
'
./event_hub
'
;
import
ProjectsService
from
'
./service/projects_service
'
;
import
ProjectsStore
from
'
./store/projects_store
'
;
import
projectsDropdownApp
from
'
./components/app.vue
'
;
Vue
.
use
(
Translate
);
document
.
addEventListener
(
'
DOMContentLoaded
'
,
()
=>
{
const
el
=
document
.
getElementById
(
'
js-projects-dropdown
'
);
const
navEl
=
document
.
getElementById
(
'
nav-projects-dropdown
'
);
// Don't do anything if element doesn't exist (No projects dropdown)
// This is for when the user accesses GitLab without logging in
if
(
!
el
||
!
navEl
)
{
return
;
}
$
(
navEl
).
on
(
'
shown.bs.dropdown
'
,
()
=>
{
eventHub
.
$emit
(
'
dropdownOpen
'
);
});
// eslint-disable-next-line no-new
new
Vue
({
el
,
components
:
{
projectsDropdownApp
,
},
data
()
{
const
{
dataset
}
=
this
.
$options
.
el
;
const
store
=
new
ProjectsStore
();
const
service
=
new
ProjectsService
(
dataset
.
userName
);
const
project
=
{
id
:
Number
(
dataset
.
projectId
),
name
:
dataset
.
projectName
,
namespace
:
dataset
.
projectNamespace
,
webUrl
:
dataset
.
projectWebUrl
,
avatarUrl
:
dataset
.
projectAvatarUrl
||
null
,
lastAccessedOn
:
Date
.
now
(),
};
return
{
store
,
service
,
state
:
store
.
state
,
currentUserName
:
dataset
.
userName
,
currentProject
:
project
,
};
},
render
(
createElement
)
{
return
createElement
(
'
projects-dropdown-app
'
,
{
props
:
{
currentUserName
:
this
.
currentUserName
,
currentProject
:
this
.
currentProject
,
store
:
this
.
store
,
service
:
this
.
service
,
},
});
},
});
});
app/assets/javascripts/projects_dropdown/service/projects_service.js
deleted
100644 → 0
View file @
b14b31b8
import
_
from
'
underscore
'
;
import
Vue
from
'
vue
'
;
import
VueResource
from
'
vue-resource
'
;
import
bp
from
'
../../breakpoints
'
;
import
Api
from
'
../../api
'
;
import
AccessorUtilities
from
'
../../lib/utils/accessor
'
;
import
{
FREQUENT_PROJECTS
,
HOUR_IN_MS
,
STORAGE_KEY
}
from
'
../constants
'
;
Vue
.
use
(
VueResource
);
export
default
class
ProjectsService
{
constructor
(
currentUserName
)
{
this
.
isLocalStorageAvailable
=
AccessorUtilities
.
isLocalStorageAccessSafe
();
this
.
currentUserName
=
currentUserName
;
this
.
storageKey
=
`
${
this
.
currentUserName
}
/
${
STORAGE_KEY
}
`
;
this
.
projectsPath
=
Vue
.
resource
(
Api
.
buildUrl
(
Api
.
projectsPath
));
}
getSearchedProjects
(
searchQuery
)
{
return
this
.
projectsPath
.
get
({
simple
:
true
,
per_page
:
20
,
membership
:
!!
gon
.
current_user_id
,
order_by
:
'
last_activity_at
'
,
search
:
searchQuery
,
});
}
getFrequentProjects
()
{
if
(
this
.
isLocalStorageAvailable
)
{
return
this
.
getTopFrequentProjects
();
}
return
null
;
}
logProjectAccess
(
project
)
{
let
matchFound
=
false
;
let
storedFrequentProjects
;
if
(
this
.
isLocalStorageAvailable
)
{
const
storedRawProjects
=
localStorage
.
getItem
(
this
.
storageKey
);
// Check if there's any frequent projects list set
if
(
!
storedRawProjects
)
{
// No frequent projects list set, set one up.
storedFrequentProjects
=
[];
storedFrequentProjects
.
push
({
...
project
,
frequency
:
1
});
}
else
{
// Check if project is already present in frequents list
// When found, update metadata of it.
storedFrequentProjects
=
JSON
.
parse
(
storedRawProjects
).
map
(
projectItem
=>
{
if
(
projectItem
.
id
===
project
.
id
)
{
matchFound
=
true
;
const
diff
=
Math
.
abs
(
project
.
lastAccessedOn
-
projectItem
.
lastAccessedOn
)
/
HOUR_IN_MS
;
const
updatedProject
=
{
...
project
,
frequency
:
projectItem
.
frequency
,
lastAccessedOn
:
projectItem
.
lastAccessedOn
,
};
// Check if duration since last access of this project
// is over an hour
if
(
diff
>
1
)
{
return
{
...
updatedProject
,
frequency
:
updatedProject
.
frequency
+
1
,
lastAccessedOn
:
Date
.
now
(),
};
}
return
{
...
updatedProject
,
};
}
return
projectItem
;
});
// Check whether currently logged project is present in frequents list
if
(
!
matchFound
)
{
// We always keep size of frequents collection to 20 projects
// out of which only 5 projects with
// highest value of `frequency` and most recent `lastAccessedOn`
// are shown in projects dropdown
if
(
storedFrequentProjects
.
length
===
FREQUENT_PROJECTS
.
MAX_COUNT
)
{
storedFrequentProjects
.
shift
();
// Remove an item from head of array
}
storedFrequentProjects
.
push
({
...
project
,
frequency
:
1
});
}
}
localStorage
.
setItem
(
this
.
storageKey
,
JSON
.
stringify
(
storedFrequentProjects
));
}
}
getTopFrequentProjects
()
{
const
storedFrequentProjects
=
JSON
.
parse
(
localStorage
.
getItem
(
this
.
storageKey
));
let
frequentProjectsCount
=
FREQUENT_PROJECTS
.
LIST_COUNT_DESKTOP
;
if
(
!
storedFrequentProjects
)
{
return
[];
}
if
(
bp
.
getBreakpointSize
()
===
'
sm
'
||
bp
.
getBreakpointSize
()
===
'
xs
'
)
{
frequentProjectsCount
=
FREQUENT_PROJECTS
.
LIST_COUNT_MOBILE
;
}
const
frequentProjects
=
storedFrequentProjects
.
filter
(
project
=>
project
.
frequency
>=
FREQUENT_PROJECTS
.
ELIGIBLE_FREQUENCY
,
);
if
(
!
frequentProjects
||
frequentProjects
.
length
===
0
)
{
return
[];
}
// Sort all frequent projects in decending order of frequency
// and then by lastAccessedOn with recent most first
frequentProjects
.
sort
((
projectA
,
projectB
)
=>
{
if
(
projectA
.
frequency
<
projectB
.
frequency
)
{
return
1
;
}
else
if
(
projectA
.
frequency
>
projectB
.
frequency
)
{
return
-
1
;
}
else
if
(
projectA
.
lastAccessedOn
<
projectB
.
lastAccessedOn
)
{
return
1
;
}
else
if
(
projectA
.
lastAccessedOn
>
projectB
.
lastAccessedOn
)
{
return
-
1
;
}
return
0
;
});
return
_
.
first
(
frequentProjects
,
frequentProjectsCount
);
}
}
app/assets/javascripts/projects_dropdown/store/projects_store.js
deleted
100644 → 0
View file @
b14b31b8
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
=
[];
}
}
app/assets/stylesheets/framework/dropdowns.scss
View file @
3892b022
...
...
@@ -36,7 +36,7 @@
width
:
100%
;
}
&
.
project
s-dropdown-menu
{
&
.
frequent-item
s-dropdown-menu
{
padding
:
0
;
overflow-y
:
initial
;
max-height
:
initial
;
...
...
@@ -790,6 +790,7 @@
@include
media-breakpoint-down
(
xs
)
{
.navbar-gitlab
{
li
.header-projects
,
li
.header-groups
,
li
.header-more
,
li
.header-new
,
li
.header-user
{
...
...
@@ -813,18 +814,18 @@
}
}
header
.header-content
.dropdown-menu.
project
s-dropdown-menu
{
header
.header-content
.dropdown-menu.
frequent-item
s-dropdown-menu
{
padding
:
0
;
}
.
project
s-dropdown-container
{
.
frequent-item
s-dropdown-container
{
display
:
flex
;
flex-direction
:
row
;
width
:
500px
;
height
:
334px
;
.
project
-dropdown-sidebar
,
.
project
-dropdown-content
{
.
frequent-items
-dropdown-sidebar
,
.
frequent-items
-dropdown-content
{
padding
:
8px
0
;
}
...
...
@@ -832,12 +833,12 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
color
:
$almost-black
;
}
.
project
-dropdown-sidebar
{
.
frequent-items
-dropdown-sidebar
{
width
:
30%
;
border-right
:
1px
solid
$border-color
;
}
.
project
-dropdown-content
{
.
frequent-items
-dropdown-content
{
position
:
relative
;
width
:
70%
;
}
...
...
@@ -848,33 +849,35 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
height
:
auto
;
flex
:
1
;
.
project
-dropdown-sidebar
,
.
project
-dropdown-content
{
.
frequent-items
-dropdown-sidebar
,
.
frequent-items
-dropdown-content
{
width
:
100%
;
}
.
project
-dropdown-sidebar
{
.
frequent-items
-dropdown-sidebar
{
border-bottom
:
1px
solid
$border-color
;
border-right
:
0
;
}
}
.projects-list-frequent-container
,
.projects-list-search-container
{
.section-header
,
.frequent-items-list-container
li
.section-empty
{
padding
:
0
$gl-padding
;
color
:
$gl-text-color-secondary
;
font-size
:
$gl-font-size
;
}
.frequent-items-list-container
{
padding
:
8px
0
;
overflow-y
:
auto
;
li
.section-empty.section-failure
{
color
:
$callout-danger-color
;
}
}
.section-header
,
.projects-list-frequent-container
li
.section-empty
,
.projects-list-search-container
li
.section-empty
{
padding
:
0
15px
;
color
:
$gl-text-color-secondary
;
font-size
:
$gl-font-size
;
.frequent-items-list-item-container
a
{
display
:
flex
;
}
}
.search-input-container
{
...
...
@@ -894,12 +897,12 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
margin-top
:
8px
;
}
.
projects-list
-search-container
{
.
frequent-items
-search-container
{
height
:
284px
;
}
@include
media-breakpoint-down
(
xs
)
{
.
projects-list-frequen
t-container
{
.
frequent-items-lis
t-container
{
width
:
auto
;
height
:
auto
;
padding-bottom
:
0
;
...
...
@@ -907,32 +910,38 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
}
}
.projects-list-item-container
{
.project-item-avatar-container
.project-item-metadata-container
{
.frequent-items-list-item-container
{
.frequent-items-item-avatar-container
,
.frequent-items-item-metadata-container
{
float
:
left
;
}
.project-title
,
.project-namespace
{
.frequent-items-item-metadata-container
{
display
:
flex
;
flex-direction
:
column
;
justify-content
:
center
;
}
.frequent-items-item-title
,
.frequent-items-item-namespace
{
max-width
:
250px
;
overflow
:
hidden
;
text-overflow
:
ellipsis
;
white-space
:
nowrap
;
}
&
:hover
{
.
project
-item-avatar-container
.avatar
{
.
frequent-items
-item-avatar-container
.avatar
{
border-color
:
$md-area-border
;
}
}
.
project
-title
{
.
frequent-items-item
-title
{
font-size
:
$gl-font-size
;
font-weight
:
400
;
line-height
:
16px
;
}
.
project
-namespace
{
.
frequent-items-item
-namespace
{
margin-top
:
4px
;
font-size
:
12px
;
line-height
:
12px
;
...
...
@@ -940,7 +949,7 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
}
@include
media-breakpoint-down
(
xs
)
{
.
project
-item-metadata-container
{
.
frequent-items
-item-metadata-container
{
float
:
none
;
}
}
...
...
app/assets/stylesheets/framework/gitlab_theme.scss
View file @
3892b022
...
...
@@ -29,16 +29,22 @@
.navbar-sub-nav
,
.navbar-nav
{
>
li
{
>
a
:hover
,
>
a
:focus
{
>
a
,
>
button
{
&
:hover
,
&
:focus
{
background-color
:
rgba
(
$search-and-nav-links
,
0
.2
);
}
}
&
.active
>
a
,
&
.dropdown.show
>
a
{
&
.active
,
&
.dropdown.show
{
>
a
,
>
button
{
color
:
$nav-svg-color
;
background-color
:
$color-alternate
;
}
}
&
.line-separator
{
border-left
:
1px
solid
rgba
(
$search-and-nav-links
,
0
.2
);
...
...
@@ -147,7 +153,6 @@
}
}
// Sidebar
.nav-sidebar
li
.active
{
box-shadow
:
inset
4px
0
0
$border-and-box-shadow
;
...
...
app/assets/stylesheets/framework/header.scss
View file @
3892b022
...
...
@@ -269,14 +269,8 @@
.navbar-sub-nav
,
.navbar-nav
{
>
li
{
>
a
:hover
,
>
a
:focus
{
text-decoration
:
none
;
outline
:
0
;
color
:
$white-light
;
}
>
a
{
>
a
,
>
button
{
display
:
-
webkit-flex
;
display
:
flex
;
align-items
:
center
;
...
...
@@ -288,6 +282,18 @@
border-radius
:
$border-radius-default
;
height
:
32px
;
font-weight
:
$gl-font-weight-bold
;
&
:hover
,
&
:focus
{
text-decoration
:
none
;
outline
:
0
;
color
:
$white-light
;
}
}
>
button
{
background
:
transparent
;
border
:
0
;
}
&
.line-separator
{
...
...
@@ -311,7 +317,7 @@
font-size
:
10px
;
}
.
project
-item-select-holder
{
.
frequent-items
-item-select-holder
{
display
:
inline
;
}
...
...
app/views/layouts/nav/_dashboard.html.haml
View file @
3892b022
%ul
.list-unstyled.navbar-sub-nav
-
if
dashboard_nav_link?
(
:projects
)
=
nav_link
(
path:
[
'root#index'
,
'projects#trending'
,
'projects#starred'
,
'dashboard/projects#index'
],
html_options:
{
id:
'nav-projects-dropdown'
,
class:
"home dropdown header-projects qa-projects-dropdown"
})
do
%
a
{
href:
"#"
,
data:
{
toggle:
"dropdown"
}
}
%
button
{
type:
'button'
,
data:
{
toggle:
"dropdown"
}
}
Projects
=
sprite_icon
(
'angle-down'
,
css_class:
'caret-down'
)
.dropdown-menu.
project
s-dropdown-menu
.dropdown-menu.
frequent-item
s-dropdown-menu
=
render
"layouts/nav/projects_dropdown/show"
-
if
dashboard_nav_link?
(
:groups
)
=
nav_link
(
controller:
[
'dashboard/groups'
,
'explore/groups'
],
html_options:
{
class:
"d-none d-sm-block
"
})
do
=
link_to
dashboard_groups_path
,
class:
'dashboard-shortcuts-groups qa-groups-link'
,
title:
'Groups'
do
=
nav_link
(
controller:
[
'dashboard/groups'
,
'explore/groups'
],
html_options:
{
id:
'nav-groups-dropdown'
,
class:
"home dropdown header-groups qa-groups-dropdown
"
})
do
%button
{
type:
'button'
,
data:
{
toggle:
"dropdown"
}
}
Groups
=
sprite_icon
(
'angle-down'
,
css_class:
'caret-down'
)
.dropdown-menu.frequent-items-dropdown-menu
=
render
"layouts/nav/groups_dropdown/show"
-
if
dashboard_nav_link?
(
:activity
)
=
nav_link
(
path:
'dashboard#activity'
,
html_options:
{
class:
"d-none d-lg-block d-xl-block"
})
do
...
...
@@ -34,11 +37,6 @@
=
sprite_icon
(
'angle-down'
,
css_class:
'caret-down'
)
.dropdown-menu
%ul
-
if
dashboard_nav_link?
(
:groups
)
=
nav_link
(
controller:
[
'dashboard/groups'
,
'explore/groups'
],
html_options:
{
class:
"d-block d-sm-none"
})
do
=
link_to
dashboard_groups_path
,
class:
'dashboard-shortcuts-groups'
,
title:
'Groups'
do
Groups
-
if
dashboard_nav_link?
(
:activity
)
=
nav_link
(
path:
'dashboard#activity'
)
do
=
link_to
activity_dashboard_path
,
title:
'Activity'
do
...
...
app/views/layouts/nav/groups_dropdown/_show.html.haml
0 → 100644
View file @
3892b022
-
group_meta
=
{
id:
@group
.
id
,
name:
@group
.
name
,
namespace:
@group
.
full_name
,
web_url:
group_path
(
@group
),
avatar_url:
@group
.
avatar_url
}
if
@group
&
.
persisted?
.frequent-items-dropdown-container
.frequent-items-dropdown-sidebar.qa-groups-dropdown-sidebar
%ul
=
nav_link
(
path:
'dashboard/groups#index'
)
do
=
link_to
dashboard_groups_path
,
class:
'qa-your-groups-link'
do
=
_
(
'Your groups'
)
=
nav_link
(
path:
'groups#explore'
)
do
=
link_to
explore_groups_path
do
=
_
(
'Explore groups'
)
.frequent-items-dropdown-content
#js-groups-dropdown
{
data:
{
user_name:
current_user
.
username
,
group:
group_meta
}
}
app/views/layouts/nav/projects_dropdown/_show.html.haml
View file @
3892b022
-
project_meta
=
{
id:
@project
.
id
,
name:
@project
.
name
,
namespace:
@project
.
full_name
,
web_url:
project_path
(
@project
),
avatar_url:
@project
.
avatar_url
}
if
@project
&
.
persisted?
.
project
s-dropdown-container
.
project
-dropdown-sidebar.qa-projects-dropdown-sidebar
.
frequent-item
s-dropdown-container
.
frequent-items
-dropdown-sidebar.qa-projects-dropdown-sidebar
%ul
=
nav_link
(
path:
'dashboard/projects#index'
)
do
=
link_to
dashboard_projects_path
,
class:
'qa-your-projects-link'
do
...
...
@@ -11,5 +11,5 @@
=
nav_link
(
path:
'projects#trending'
)
do
=
link_to
explore_root_path
do
=
_
(
'Explore projects'
)
.
project
-dropdown-content
.
frequent-items
-dropdown-content
#js-projects-dropdown
{
data:
{
user_name:
current_user
.
username
,
project:
project_meta
}
}
changelogs/unreleased/36234-nav-add-groups-dropdown.yml
0 → 100644
View file @
3892b022
---
title
:
Add dropdown to Groups link in top bar
merge_request
:
18280
author
:
type
:
added
qa/qa/page/menu/main.rb
View file @
3892b022
...
...
@@ -16,7 +16,7 @@ module QA
view
'app/views/layouts/nav/_dashboard.html.haml'
do
element
:admin_area_link
element
:projects_dropdown
element
:groups_
link
element
:groups_
dropdown
end
view
'app/views/layouts/nav/projects_dropdown/_show.html.haml'
do
...
...
@@ -25,7 +25,13 @@ module QA
end
def
go_to_groups
within_top_menu
{
click_element
:groups_link
}
within_top_menu
do
click_element
:groups_dropdown
end
page
.
within
(
'.qa-groups-dropdown-sidebar'
)
do
click_element
:your_groups_link
end
end
def
go_to_projects
...
...
spec/javascripts/frequent_items/components/app_spec.js
0 → 100644
View file @
3892b022
import
MockAdapter
from
'
axios-mock-adapter
'
;
import
axios
from
'
~/lib/utils/axios_utils
'
;
import
Vue
from
'
vue
'
;
import
appComponent
from
'
~/frequent_items/components/app.vue
'
;
import
eventHub
from
'
~/frequent_items/event_hub
'
;
import
store
from
'
~/frequent_items/store
'
;
import
{
FREQUENT_ITEMS
,
HOUR_IN_MS
}
from
'
~/frequent_items/constants
'
;
import
{
getTopFrequentItems
}
from
'
~/frequent_items/utils
'
;
import
{
mountComponentWithStore
}
from
'
spec/helpers/vue_mount_component_helper
'
;
import
{
currentSession
,
mockFrequentProjects
,
mockSearchedProjects
}
from
'
../mock_data
'
;
let
session
;
const
createComponentWithStore
=
(
namespace
=
'
projects
'
)
=>
{
session
=
currentSession
[
namespace
];
gon
.
api_version
=
session
.
apiVersion
;
const
Component
=
Vue
.
extend
(
appComponent
);
return
mountComponentWithStore
(
Component
,
{
store
,
props
:
{
namespace
,
currentUserName
:
session
.
username
,
currentItem
:
session
.
project
||
session
.
group
,
},
});
};
describe
(
'
Frequent Items App Component
'
,
()
=>
{
let
vm
;
let
mock
;
beforeEach
(()
=>
{
mock
=
new
MockAdapter
(
axios
);
vm
=
createComponentWithStore
();
});
afterEach
(()
=>
{
mock
.
restore
();
vm
.
$destroy
();
});
describe
(
'
methods
'
,
()
=>
{
describe
(
'
dropdownOpenHandler
'
,
()
=>
{
it
(
'
should fetch frequent items when no search has been previously made on desktop
'
,
()
=>
{
spyOn
(
vm
,
'
fetchFrequentItems
'
);
vm
.
dropdownOpenHandler
();
expect
(
vm
.
fetchFrequentItems
).
toHaveBeenCalledWith
();
});
});
describe
(
'
logItemAccess
'
,
()
=>
{
let
storage
;
beforeEach
(()
=>
{
storage
=
{};
spyOn
(
window
.
localStorage
,
'
setItem
'
).
and
.
callFake
((
storageKey
,
value
)
=>
{
storage
[
storageKey
]
=
value
;
});
spyOn
(
window
.
localStorage
,
'
getItem
'
).
and
.
callFake
(
storageKey
=>
{
if
(
storage
[
storageKey
])
{
return
storage
[
storageKey
];
}
return
null
;
});
});
it
(
'
should create a project store if it does not exist and adds a project
'
,
()
=>
{
vm
.
logItemAccess
(
session
.
storageKey
,
session
.
project
);
const
projects
=
JSON
.
parse
(
storage
[
session
.
storageKey
]);
expect
(
projects
.
length
).
toBe
(
1
);
expect
(
projects
[
0
].
frequency
).
toBe
(
1
);
expect
(
projects
[
0
].
lastAccessedOn
).
toBeDefined
();
});
it
(
'
should prevent inserting same report multiple times into store
'
,
()
=>
{
vm
.
logItemAccess
(
session
.
storageKey
,
session
.
project
);
vm
.
logItemAccess
(
session
.
storageKey
,
session
.
project
);
const
projects
=
JSON
.
parse
(
storage
[
session
.
storageKey
]);
expect
(
projects
.
length
).
toBe
(
1
);
});
it
(
'
should increase frequency of report if it was logged multiple times over the course of an hour
'
,
()
=>
{
let
projects
;
const
newTimestamp
=
Date
.
now
()
+
HOUR_IN_MS
+
1
;
vm
.
logItemAccess
(
session
.
storageKey
,
session
.
project
);
projects
=
JSON
.
parse
(
storage
[
session
.
storageKey
]);
expect
(
projects
[
0
].
frequency
).
toBe
(
1
);
vm
.
logItemAccess
(
session
.
storageKey
,
{
...
session
.
project
,
lastAccessedOn
:
newTimestamp
,
});
projects
=
JSON
.
parse
(
storage
[
session
.
storageKey
]);
expect
(
projects
[
0
].
frequency
).
toBe
(
2
);
expect
(
projects
[
0
].
lastAccessedOn
).
not
.
toBe
(
session
.
project
.
lastAccessedOn
);
});
it
(
'
should always update project metadata
'
,
()
=>
{
let
projects
;
const
oldProject
=
{
...
session
.
project
,
};
const
newProject
=
{
...
session
.
project
,
name
:
'
New Name
'
,
avatarUrl
:
'
new/avatar.png
'
,
namespace
:
'
New / Namespace
'
,
webUrl
:
'
http://localhost/new/web/url
'
,
};
vm
.
logItemAccess
(
session
.
storageKey
,
oldProject
);
projects
=
JSON
.
parse
(
storage
[
session
.
storageKey
]);
expect
(
projects
[
0
].
name
).
toBe
(
oldProject
.
name
);
expect
(
projects
[
0
].
avatarUrl
).
toBe
(
oldProject
.
avatarUrl
);
expect
(
projects
[
0
].
namespace
).
toBe
(
oldProject
.
namespace
);
expect
(
projects
[
0
].
webUrl
).
toBe
(
oldProject
.
webUrl
);
vm
.
logItemAccess
(
session
.
storageKey
,
newProject
);
projects
=
JSON
.
parse
(
storage
[
session
.
storageKey
]);
expect
(
projects
[
0
].
name
).
toBe
(
newProject
.
name
);
expect
(
projects
[
0
].
avatarUrl
).
toBe
(
newProject
.
avatarUrl
);
expect
(
projects
[
0
].
namespace
).
toBe
(
newProject
.
namespace
);
expect
(
projects
[
0
].
webUrl
).
toBe
(
newProject
.
webUrl
);
});
it
(
'
should not add more than 20 projects in store
'
,
()
=>
{
for
(
let
id
=
0
;
id
<
FREQUENT_ITEMS
.
MAX_COUNT
;
id
+=
1
)
{
const
project
=
{
...
session
.
project
,
id
,
};
vm
.
logItemAccess
(
session
.
storageKey
,
project
);
}
const
projects
=
JSON
.
parse
(
storage
[
session
.
storageKey
]);
expect
(
projects
.
length
).
toBe
(
FREQUENT_ITEMS
.
MAX_COUNT
);
});
});
});
describe
(
'
created
'
,
()
=>
{
it
(
'
should bind event listeners on eventHub
'
,
done
=>
{
spyOn
(
eventHub
,
'
$on
'
);
createComponentWithStore
().
$mount
();
Vue
.
nextTick
(()
=>
{
expect
(
eventHub
.
$on
).
toHaveBeenCalledWith
(
'
projects-dropdownOpen
'
,
jasmine
.
any
(
Function
));
done
();
});
});
});
describe
(
'
beforeDestroy
'
,
()
=>
{
it
(
'
should unbind event listeners on eventHub
'
,
done
=>
{
spyOn
(
eventHub
,
'
$off
'
);
vm
.
$mount
();
vm
.
$destroy
();
Vue
.
nextTick
(()
=>
{
expect
(
eventHub
.
$off
).
toHaveBeenCalledWith
(
'
projects-dropdownOpen
'
,
jasmine
.
any
(
Function
));
done
();
});
});
});
describe
(
'
template
'
,
()
=>
{
it
(
'
should render search input
'
,
()
=>
{
expect
(
vm
.
$el
.
querySelector
(
'
.search-input-container
'
)).
toBeDefined
();
});
it
(
'
should render loading animation
'
,
done
=>
{
vm
.
$store
.
dispatch
(
'
fetchSearchedItems
'
);
Vue
.
nextTick
(()
=>
{
const
loadingEl
=
vm
.
$el
.
querySelector
(
'
.loading-animation
'
);
expect
(
loadingEl
).
toBeDefined
();
expect
(
loadingEl
.
classList
.
contains
(
'
prepend-top-20
'
)).
toBe
(
true
);
expect
(
loadingEl
.
querySelector
(
'
i
'
).
getAttribute
(
'
aria-label
'
)).
toBe
(
'
Loading projects
'
);
done
();
});
});
it
(
'
should render frequent projects list header
'
,
done
=>
{
Vue
.
nextTick
(()
=>
{
const
sectionHeaderEl
=
vm
.
$el
.
querySelector
(
'
.section-header
'
);
expect
(
sectionHeaderEl
).
toBeDefined
();
expect
(
sectionHeaderEl
.
innerText
.
trim
()).
toBe
(
'
Frequently visited
'
);
done
();
});
});
it
(
'
should render frequent projects list
'
,
done
=>
{
const
expectedResult
=
getTopFrequentItems
(
mockFrequentProjects
);
spyOn
(
window
.
localStorage
,
'
getItem
'
).
and
.
callFake
(()
=>
JSON
.
stringify
(
mockFrequentProjects
),
);
expect
(
vm
.
$el
.
querySelectorAll
(
'
.frequent-items-list-container li
'
).
length
).
toBe
(
1
);
vm
.
fetchFrequentItems
();
Vue
.
nextTick
(()
=>
{
expect
(
vm
.
$el
.
querySelectorAll
(
'
.frequent-items-list-container li
'
).
length
).
toBe
(
expectedResult
.
length
,
);
done
();
});
});
it
(
'
should render searched projects list
'
,
done
=>
{
mock
.
onGet
(
/
\/
api
\/
v4
\/
projects.json
(
.*
)
$/
).
replyOnce
(
200
,
mockSearchedProjects
);
expect
(
vm
.
$el
.
querySelectorAll
(
'
.frequent-items-list-container li
'
).
length
).
toBe
(
1
);
vm
.
$store
.
dispatch
(
'
setSearchQuery
'
,
'
gitlab
'
);
vm
.
$nextTick
()
.
then
(()
=>
{
expect
(
vm
.
$el
.
querySelector
(
'
.loading-animation
'
)).
toBeDefined
();
})
.
then
(
vm
.
$nextTick
)
.
then
(
vm
.
$nextTick
)
.
then
(()
=>
{
expect
(
vm
.
$el
.
querySelectorAll
(
'
.frequent-items-list-container li
'
).
length
).
toBe
(
mockSearchedProjects
.
length
,
);
})
.
then
(
done
)
.
catch
(
done
.
fail
);
});
});
});
spec/javascripts/
projects_dropdown/components/project
s_list_item_spec.js
→
spec/javascripts/
frequent_items/components/frequent_item
s_list_item_spec.js
View file @
3892b022
import
Vue
from
'
vue
'
;
import
projectsListItemComponent
from
'
~/projects_dropdown/components/projects_list_item.vue
'
;
import
frequentItemsListItemComponent
from
'
~/frequent_items/components/frequent_items_list_item.vue
'
;
import
mountComponent
from
'
spec/helpers/vue_mount_component_helper
'
;
import
{
mockProject
}
from
'
../mock_data
'
;
import
{
mockProject
}
from
'
../mock_data
'
;
// can also use 'mockGroup', but not useful to test here
const
createComponent
=
()
=>
{
const
Component
=
Vue
.
extend
(
project
sListItemComponent
);
const
Component
=
Vue
.
extend
(
frequentItem
sListItemComponent
);
return
mountComponent
(
Component
,
{
project
Id
:
mockProject
.
id
,
project
Name
:
mockProject
.
name
,
item
Id
:
mockProject
.
id
,
item
Name
:
mockProject
.
name
,
namespace
:
mockProject
.
namespace
,
webUrl
:
mockProject
.
webUrl
,
avatarUrl
:
mockProject
.
avatarUrl
,
});
};
describe
(
'
Project
sListItemComponent
'
,
()
=>
{
describe
(
'
FrequentItem
sListItemComponent
'
,
()
=>
{
let
vm
;
beforeEach
(()
=>
{
...
...
@@ -32,22 +30,22 @@ describe('ProjectsListItemComponent', () => {
describe
(
'
hasAvatar
'
,
()
=>
{
it
(
'
should return `true` or `false` if whether avatar is present or not
'
,
()
=>
{
vm
.
avatarUrl
=
'
path/to/avatar.png
'
;
expect
(
vm
.
hasAvatar
).
toBe
Truthy
(
);
expect
(
vm
.
hasAvatar
).
toBe
(
true
);
vm
.
avatarUrl
=
null
;
expect
(
vm
.
hasAvatar
).
toBe
Falsy
(
);
expect
(
vm
.
hasAvatar
).
toBe
(
false
);
});
});
describe
(
'
highlighted
Project
Name
'
,
()
=>
{
describe
(
'
highlighted
Item
Name
'
,
()
=>
{
it
(
'
should enclose part of project name in <b> & </b> which matches with `matcher` prop
'
,
()
=>
{
vm
.
matcher
=
'
lab
'
;
expect
(
vm
.
highlighted
Project
Name
).
toContain
(
'
<b>Lab</b>
'
);
expect
(
vm
.
highlighted
Item
Name
).
toContain
(
'
<b>Lab</b>
'
);
});
it
(
'
should return project name as it is if `matcher` is not available
'
,
()
=>
{
vm
.
matcher
=
null
;
expect
(
vm
.
highlighted
Project
Name
).
toBe
(
mockProject
.
name
);
expect
(
vm
.
highlighted
Item
Name
).
toBe
(
mockProject
.
name
);
});
});
...
...
@@ -66,12 +64,12 @@ describe('ProjectsListItemComponent', () => {
describe
(
'
template
'
,
()
=>
{
it
(
'
should render component element
'
,
()
=>
{
expect
(
vm
.
$el
.
classList
.
contains
(
'
project
s-list-item-container
'
)).
toBeTruthy
();
expect
(
vm
.
$el
.
classList
.
contains
(
'
frequent-item
s-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
);
expect
(
vm
.
$el
.
querySelectorAll
(
'
.
frequent-items
-item-avatar-container
'
).
length
).
toBe
(
1
);
expect
(
vm
.
$el
.
querySelectorAll
(
'
.
frequent-items
-item-metadata-container
'
).
length
).
toBe
(
1
);
expect
(
vm
.
$el
.
querySelectorAll
(
'
.
frequent-items-item
-title
'
).
length
).
toBe
(
1
);
expect
(
vm
.
$el
.
querySelectorAll
(
'
.
frequent-items-item
-namespace
'
).
length
).
toBe
(
1
);
});
});
});
spec/javascripts/
projects_dropdown/components/projects_list_search
_spec.js
→
spec/javascripts/
frequent_items/components/frequent_items_list
_spec.js
View file @
3892b022
import
Vue
from
'
vue
'
;
import
projectsListSearchComponent
from
'
~/projects_dropdown/components/projects_list_search.vue
'
;
import
frequentItemsListComponent
from
'
~/frequent_items/components/frequent_items_list.vue
'
;
import
mountComponent
from
'
spec/helpers/vue_mount_component_helper
'
;
import
{
mock
Project
}
from
'
../mock_data
'
;
import
{
mock
FrequentProjects
}
from
'
../mock_data
'
;
const
createComponent
=
()
=>
{
const
Component
=
Vue
.
extend
(
projectsListSearch
Component
);
const
createComponent
=
(
namespace
=
'
projects
'
)
=>
{
const
Component
=
Vue
.
extend
(
frequentItemsList
Component
);
return
mountComponent
(
Component
,
{
projects
:
[
mockProject
],
namespace
,
items
:
mockFrequentProjects
,
isFetchFailed
:
false
,
hasSearchQuery
:
false
,
matcher
:
'
lab
'
,
searchFailed
:
false
,
});
};
describe
(
'
ProjectsListSearch
Component
'
,
()
=>
{
describe
(
'
FrequentItemsList
Component
'
,
()
=>
{
let
vm
;
beforeEach
(()
=>
{
...
...
@@ -28,55 +28,55 @@ describe('ProjectsListSearchComponent', () => {
describe
(
'
computed
'
,
()
=>
{
describe
(
'
isListEmpty
'
,
()
=>
{
it
(
'
should return `true` or `false` representing whether if `projects` is empty of not
'
,
()
=>
{
vm
.
projects
=
[];
expect
(
vm
.
isListEmpty
).
toBeTruthy
();
it
(
'
should return `true` or `false` representing whether if `items` is empty or not with projects
'
,
()
=>
{
vm
.
items
=
[];
expect
(
vm
.
isListEmpty
).
toBe
(
true
);
vm
.
items
=
mockFrequentProjects
;
expect
(
vm
.
isListEmpty
).
toBe
(
false
);
});
});
describe
(
'
fetched item messages
'
,
()
=>
{
it
(
'
should return appropriate empty list message based on value of `localStorageFailed` prop with projects
'
,
()
=>
{
vm
.
isFetchFailed
=
true
;
expect
(
vm
.
listEmptyMessage
).
toBe
(
'
This feature requires browser localStorage support
'
);
vm
.
projects
=
[
mockProject
]
;
expect
(
vm
.
isListEmpty
).
toBeFalsy
(
);
vm
.
isFetchFailed
=
false
;
expect
(
vm
.
listEmptyMessage
).
toBe
(
'
Projects you visit often will appear here
'
);
});
});
describe
(
'
listEmptyMessage
'
,
()
=>
{
it
(
'
should return appropriate empty list message based on value of `searchFailed` prop
'
,
()
=>
{
vm
.
searchFailed
=
true
;
describe
(
'
searched item messages
'
,
()
=>
{
it
(
'
should return appropriate empty list message based on value of `searchFailed` prop with projects
'
,
()
=>
{
vm
.
hasSearchQuery
=
true
;
vm
.
isFetchFailed
=
true
;
expect
(
vm
.
listEmptyMessage
).
toBe
(
'
Something went wrong on our end.
'
);
vm
.
sear
chFailed
=
false
;
vm
.
isFet
chFailed
=
false
;
expect
(
vm
.
listEmptyMessage
).
toBe
(
'
Sorry, no projects matched your search
'
);
});
});
});
describe
(
'
template
'
,
()
=>
{
it
(
'
should render component element with list of projects
'
,
(
done
)
=>
{
vm
.
projects
=
[
mockProject
]
;
it
(
'
should render component element with list of projects
'
,
done
=>
{
vm
.
items
=
mockFrequentProjects
;
Vue
.
nextTick
(()
=>
{
expect
(
vm
.
$el
.
classList
.
contains
(
'
projects-list-search-container
'
)).
toBeTruthy
(
);
expect
(
vm
.
$el
.
classList
.
contains
(
'
frequent-items-list-container
'
)).
toBe
(
true
);
expect
(
vm
.
$el
.
querySelectorAll
(
'
ul.list-unstyled
'
).
length
).
toBe
(
1
);
expect
(
vm
.
$el
.
querySelectorAll
(
'
li.
projects-list-item-container
'
).
length
).
toBe
(
1
);
expect
(
vm
.
$el
.
querySelectorAll
(
'
li.
frequent-items-list-item-container
'
).
length
).
toBe
(
5
);
done
();
});
});
it
(
'
should render component element with empty message
'
,
(
done
)
=>
{
vm
.
project
s
=
[];
it
(
'
should render component element with empty message
'
,
done
=>
{
vm
.
item
s
=
[];
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
);
expect
(
vm
.
$el
.
querySelectorAll
(
'
li.frequent-items-list-item-container
'
).
length
).
toBe
(
0
);
done
();
});
});
...
...
spec/javascripts/
projects_dropdown/components/search
_spec.js
→
spec/javascripts/
frequent_items/components/frequent_items_search_input
_spec.js
View file @
3892b022
import
Vue
from
'
vue
'
;
import
searchComponent
from
'
~/projects_dropdown/components/search.vue
'
;
import
eventHub
from
'
~/projects_dropdown/event_hub
'
;
import
searchComponent
from
'
~/frequent_items/components/frequent_items_search_input.vue
'
;
import
eventHub
from
'
~/frequent_items/event_hub
'
;
import
mountComponent
from
'
spec/helpers/vue_mount_component_helper
'
;
const
createComponent
=
()
=>
{
const
createComponent
=
(
namespace
=
'
projects
'
)
=>
{
const
Component
=
Vue
.
extend
(
searchComponent
);
return
mountComponent
(
Component
);
return
mountComponent
(
Component
,
{
namespace
}
);
};
describe
(
'
SearchComponent
'
,
()
=>
{
describe
(
'
methods
'
,
()
=>
{
describe
(
'
FrequentItemsSearchInputComponent
'
,
()
=>
{
let
vm
;
beforeEach
(()
=>
{
...
...
@@ -23,6 +20,7 @@ describe('SearchComponent', () => {
vm
.
$destroy
();
});
describe
(
'
methods
'
,
()
=>
{
describe
(
'
setFocus
'
,
()
=>
{
it
(
'
should set focus to search input
'
,
()
=>
{
spyOn
(
vm
.
$refs
.
search
,
'
focus
'
);
...
...
@@ -31,63 +29,42 @@ describe('SearchComponent', () => {
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
)
=>
{
it
(
'
should listen `dropdownOpen` event
'
,
done
=>
{
spyOn
(
eventHub
,
'
$on
'
);
createComponent
();
c
onst
vmX
=
c
reateComponent
();
Vue
.
nextTick
(()
=>
{
expect
(
eventHub
.
$on
).
toHaveBeenCalledWith
(
'
dropdownOpen
'
,
jasmine
.
any
(
Function
));
expect
(
eventHub
.
$on
).
toHaveBeenCalledWith
(
`
${
vmX
.
namespace
}
-dropdownOpen`
,
jasmine
.
any
(
Function
),
);
done
();
});
});
});
describe
(
'
beforeDestroy
'
,
()
=>
{
it
(
'
should unbind event listeners on eventHub
'
,
(
done
)
=>
{
const
vm
=
createComponent
();
it
(
'
should unbind event listeners on eventHub
'
,
done
=>
{
const
vm
X
=
createComponent
();
spyOn
(
eventHub
,
'
$off
'
);
vm
.
$mount
();
vm
.
$destroy
();
vm
X
.
$mount
();
vm
X
.
$destroy
();
Vue
.
nextTick
(()
=>
{
expect
(
eventHub
.
$off
).
toHaveBeenCalledWith
(
'
dropdownOpen
'
,
jasmine
.
any
(
Function
));
expect
(
eventHub
.
$off
).
toHaveBeenCalledWith
(
`
${
vmX
.
namespace
}
-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
'
);
...
...
spec/javascripts/
projects_dropdown
/mock_data.js
→
spec/javascripts/
frequent_items
/mock_data.js
View file @
3892b022
export
const
currentSession
=
{
groups
:
{
username
:
'
root
'
,
storageKey
:
'
root/frequent-groups
'
,
apiVersion
:
'
v4
'
,
group
:
{
id
:
1
,
name
:
'
dummy-group
'
,
full_name
:
'
dummy-parent-group
'
,
webUrl
:
`
${
gl
.
TEST_HOST
}
/dummy-group`
,
avatarUrl
:
null
,
lastAccessedOn
:
Date
.
now
(),
},
},
projects
:
{
username
:
'
root
'
,
storageKey
:
'
root/frequent-projects
'
,
apiVersion
:
'
v4
'
,
project
:
{
id
:
1
,
name
:
'
dummy-project
'
,
namespace
:
'
Same
pleGroup / Dummy-Project
'
,
webUrl
:
'
http://127.0.0.1/samplegroup/dummy-project
'
,
namespace
:
'
Sam
pleGroup / Dummy-Project
'
,
webUrl
:
`
${
gl
.
TEST_HOST
}
/samplegroup/dummy-project`
,
avatarUrl
:
null
,
lastAccessedOn
:
Date
.
now
(),
},
},
};
export
const
mockNamespace
=
'
projects
'
;
export
const
mockStorageKey
=
'
test-user/frequent-projects
'
;
export
const
mockGroup
=
{
id
:
1
,
name
:
'
Sub451
'
,
namespace
:
'
Commit451 / Sub451
'
,
webUrl
:
`
${
gl
.
TEST_HOST
}
/Commit451/Sub451`
,
avatarUrl
:
null
,
};
export
const
mockRawGroup
=
{
id
:
1
,
name
:
'
Sub451
'
,
full_name
:
'
Commit451 / Sub451
'
,
web_url
:
`
${
gl
.
TEST_HOST
}
/Commit451/Sub451`
,
avatar_url
:
null
,
};
export
const
mockFrequentGroups
=
[
{
id
:
3
,
name
:
'
Subgroup451
'
,
full_name
:
'
Commit451 / Subgroup451
'
,
webUrl
:
'
/Commit451/Subgroup451
'
,
avatarUrl
:
null
,
frequency
:
7
,
lastAccessedOn
:
1497979281815
,
},
{
id
:
1
,
name
:
'
Commit451
'
,
full_name
:
'
Commit451
'
,
webUrl
:
'
/Commit451
'
,
avatarUrl
:
null
,
frequency
:
3
,
lastAccessedOn
:
1497979281815
,
},
];
export
const
mockSearchedGroups
=
[
mockRawGroup
];
export
const
mockProcessedSearchedGroups
=
[
mockGroup
];
export
const
mockProject
=
{
id
:
1
,
name
:
'
GitLab Community Edition
'
,
namespace
:
'
gitlab-org / gitlab-ce
'
,
webUrl
:
'
http://127.0.0.1:3000/gitlab-org/gitlab-ce
'
,
webUrl
:
`
${
gl
.
TEST_HOST
}
/gitlab-org/gitlab-ce`
,
avatarUrl
:
null
,
};
...
...
@@ -24,49 +83,62 @@ 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
'
,
web_url
:
`
${
gl
.
TEST_HOST
}
/gitlab-org/gitlab-ce`
,
avatar_url
:
null
,
};
export
const
mockFrequents
=
[
export
const
mockFrequent
Project
s
=
[
{
id
:
1
,
name
:
'
GitLab Community Edition
'
,
namespace
:
'
gitlab-org / gitlab-ce
'
,
webUrl
:
'
http://127.0.0.1:3000/gitlab-org/gitlab-ce
'
,
webUrl
:
`
${
gl
.
TEST_HOST
}
/gitlab-org/gitlab-ce`
,
avatarUrl
:
null
,
frequency
:
1
,
lastAccessedOn
:
Date
.
now
(),
},
{
id
:
2
,
name
:
'
GitLab CI
'
,
namespace
:
'
gitlab-org / gitlab-ci
'
,
webUrl
:
'
http://127.0.0.1:3000/gitlab-org/gitlab-ci
'
,
webUrl
:
`
${
gl
.
TEST_HOST
}
/gitlab-org/gitlab-ci`
,
avatarUrl
:
null
,
frequency
:
9
,
lastAccessedOn
:
Date
.
now
(),
},
{
id
:
3
,
name
:
'
Typeahead.Js
'
,
namespace
:
'
twitter / typeahead-js
'
,
webUrl
:
'
http://127.0.0.1:3000/twitter/typeahead-js
'
,
webUrl
:
`
${
gl
.
TEST_HOST
}
/twitter/typeahead-js`
,
avatarUrl
:
'
/uploads/-/system/project/avatar/7/TWBS.png
'
,
frequency
:
2
,
lastAccessedOn
:
Date
.
now
(),
},
{
id
:
4
,
name
:
'
Intel
'
,
namespace
:
'
platform / hardware / bsp / intel
'
,
webUrl
:
'
http://127.0.0.1:3000/platform/hardware/bsp/intel
'
,
webUrl
:
`
${
gl
.
TEST_HOST
}
/platform/hardware/bsp/intel`
,
avatarUrl
:
null
,
frequency
:
3
,
lastAccessedOn
:
Date
.
now
(),
},
{
id
:
5
,
name
:
'
v4.4
'
,
namespace
:
'
platform / hardware / bsp / kernel / common / v4.4
'
,
webUrl
:
'
http://localhost:3000/platform/hardware/bsp/kernel/common/v4.4
'
,
webUrl
:
`
${
gl
.
TEST_HOST
}
/platform/hardware/bsp/kernel/common/v4.4`
,
avatarUrl
:
null
,
frequency
:
8
,
lastAccessedOn
:
Date
.
now
(),
},
];
export
const
unsortedFrequents
=
[
export
const
mockSearchedProjects
=
[
mockRawProject
];
export
const
mockProcessedSearchedProjects
=
[
mockProject
];
export
const
unsortedFrequentItems
=
[
{
id
:
1
,
frequency
:
12
,
lastAccessedOn
:
1491400843391
},
{
id
:
2
,
frequency
:
14
,
lastAccessedOn
:
1488240890738
},
{
id
:
3
,
frequency
:
44
,
lastAccessedOn
:
1497675908472
},
...
...
@@ -80,10 +152,10 @@ export const unsortedFrequents = [
/**
* This const has a specific order which tests authenticity
* of `
ProjectsService.getTopFrequentProject
s` method so
* of `
getTopFrequentItem
s` method so
* DO NOT change order of items in this const.
*/
export
const
sortedFrequents
=
[
export
const
sortedFrequent
Item
s
=
[
{
id
:
10
,
frequency
:
46
,
lastAccessedOn
:
1483251641543
},
{
id
:
3
,
frequency
:
44
,
lastAccessedOn
:
1497675908472
},
{
id
:
7
,
frequency
:
42
,
lastAccessedOn
:
1486815299875
},
...
...
spec/javascripts/frequent_items/store/actions_spec.js
0 → 100644
View file @
3892b022
import
testAction
from
'
spec/helpers/vuex_action_helper
'
;
import
MockAdapter
from
'
axios-mock-adapter
'
;
import
axios
from
'
~/lib/utils/axios_utils
'
;
import
AccessorUtilities
from
'
~/lib/utils/accessor
'
;
import
*
as
actions
from
'
~/frequent_items/store/actions
'
;
import
*
as
types
from
'
~/frequent_items/store/mutation_types
'
;
import
state
from
'
~/frequent_items/store/state
'
;
import
{
mockNamespace
,
mockStorageKey
,
mockFrequentProjects
,
mockSearchedProjects
,
}
from
'
../mock_data
'
;
describe
(
'
Frequent Items Dropdown Store Actions
'
,
()
=>
{
let
mockedState
;
let
mock
;
beforeEach
(()
=>
{
mockedState
=
state
();
mock
=
new
MockAdapter
(
axios
);
mockedState
.
namespace
=
mockNamespace
;
mockedState
.
storageKey
=
mockStorageKey
;
});
afterEach
(()
=>
{
mock
.
restore
();
});
describe
(
'
setNamespace
'
,
()
=>
{
it
(
'
should set namespace
'
,
done
=>
{
testAction
(
actions
.
setNamespace
,
mockNamespace
,
mockedState
,
[{
type
:
types
.
SET_NAMESPACE
,
payload
:
mockNamespace
}],
[],
done
,
);
});
});
describe
(
'
setStorageKey
'
,
()
=>
{
it
(
'
should set storage key
'
,
done
=>
{
testAction
(
actions
.
setStorageKey
,
mockStorageKey
,
mockedState
,
[{
type
:
types
.
SET_STORAGE_KEY
,
payload
:
mockStorageKey
}],
[],
done
,
);
});
});
describe
(
'
requestFrequentItems
'
,
()
=>
{
it
(
'
should request frequent items
'
,
done
=>
{
testAction
(
actions
.
requestFrequentItems
,
null
,
mockedState
,
[{
type
:
types
.
REQUEST_FREQUENT_ITEMS
}],
[],
done
,
);
});
});
describe
(
'
receiveFrequentItemsSuccess
'
,
()
=>
{
it
(
'
should set frequent items
'
,
done
=>
{
testAction
(
actions
.
receiveFrequentItemsSuccess
,
mockFrequentProjects
,
mockedState
,
[{
type
:
types
.
RECEIVE_FREQUENT_ITEMS_SUCCESS
,
payload
:
mockFrequentProjects
}],
[],
done
,
);
});
});
describe
(
'
receiveFrequentItemsError
'
,
()
=>
{
it
(
'
should set frequent items error state
'
,
done
=>
{
testAction
(
actions
.
receiveFrequentItemsError
,
null
,
mockedState
,
[{
type
:
types
.
RECEIVE_FREQUENT_ITEMS_ERROR
}],
[],
done
,
);
});
});
describe
(
'
fetchFrequentItems
'
,
()
=>
{
it
(
'
should dispatch `receiveFrequentItemsSuccess`
'
,
done
=>
{
mockedState
.
namespace
=
mockNamespace
;
mockedState
.
storageKey
=
mockStorageKey
;
testAction
(
actions
.
fetchFrequentItems
,
null
,
mockedState
,
[],
[{
type
:
'
requestFrequentItems
'
},
{
type
:
'
receiveFrequentItemsSuccess
'
,
payload
:
[]
}],
done
,
);
});
it
(
'
should dispatch `receiveFrequentItemsError`
'
,
done
=>
{
spyOn
(
AccessorUtilities
,
'
isLocalStorageAccessSafe
'
).
and
.
returnValue
(
false
);
mockedState
.
namespace
=
mockNamespace
;
mockedState
.
storageKey
=
mockStorageKey
;
testAction
(
actions
.
fetchFrequentItems
,
null
,
mockedState
,
[],
[{
type
:
'
requestFrequentItems
'
},
{
type
:
'
receiveFrequentItemsError
'
}],
done
,
);
});
});
describe
(
'
requestSearchedItems
'
,
()
=>
{
it
(
'
should request searched items
'
,
done
=>
{
testAction
(
actions
.
requestSearchedItems
,
null
,
mockedState
,
[{
type
:
types
.
REQUEST_SEARCHED_ITEMS
}],
[],
done
,
);
});
});
describe
(
'
receiveSearchedItemsSuccess
'
,
()
=>
{
it
(
'
should set searched items
'
,
done
=>
{
testAction
(
actions
.
receiveSearchedItemsSuccess
,
mockSearchedProjects
,
mockedState
,
[{
type
:
types
.
RECEIVE_SEARCHED_ITEMS_SUCCESS
,
payload
:
mockSearchedProjects
}],
[],
done
,
);
});
});
describe
(
'
receiveSearchedItemsError
'
,
()
=>
{
it
(
'
should set searched items error state
'
,
done
=>
{
testAction
(
actions
.
receiveSearchedItemsError
,
null
,
mockedState
,
[{
type
:
types
.
RECEIVE_SEARCHED_ITEMS_ERROR
}],
[],
done
,
);
});
});
describe
(
'
fetchSearchedItems
'
,
()
=>
{
beforeEach
(()
=>
{
gon
.
api_version
=
'
v4
'
;
});
it
(
'
should dispatch `receiveSearchedItemsSuccess`
'
,
done
=>
{
mock
.
onGet
(
/
\/
api
\/
v4
\/
projects.json
(
.*
)
$/
).
replyOnce
(
200
,
mockSearchedProjects
);
testAction
(
actions
.
fetchSearchedItems
,
null
,
mockedState
,
[],
[
{
type
:
'
requestSearchedItems
'
},
{
type
:
'
receiveSearchedItemsSuccess
'
,
payload
:
mockSearchedProjects
},
],
done
,
);
});
it
(
'
should dispatch `receiveSearchedItemsError`
'
,
done
=>
{
gon
.
api_version
=
'
v4
'
;
mock
.
onGet
(
/
\/
api
\/
v4
\/
projects.json
(
.*
)
$/
).
replyOnce
(
500
);
testAction
(
actions
.
fetchSearchedItems
,
null
,
mockedState
,
[],
[{
type
:
'
requestSearchedItems
'
},
{
type
:
'
receiveSearchedItemsError
'
}],
done
,
);
});
});
describe
(
'
setSearchQuery
'
,
()
=>
{
it
(
'
should commit query and dispatch `fetchSearchedItems` when query is present
'
,
done
=>
{
testAction
(
actions
.
setSearchQuery
,
{
query
:
'
test
'
},
mockedState
,
[{
type
:
types
.
SET_SEARCH_QUERY
}],
[{
type
:
'
fetchSearchedItems
'
,
payload
:
{
query
:
'
test
'
}
}],
done
,
);
});
it
(
'
should commit query and dispatch `fetchFrequentItems` when query is empty
'
,
done
=>
{
testAction
(
actions
.
setSearchQuery
,
null
,
mockedState
,
[{
type
:
types
.
SET_SEARCH_QUERY
}],
[{
type
:
'
fetchFrequentItems
'
}],
done
,
);
});
});
});
spec/javascripts/frequent_items/store/getters_spec.js
0 → 100644
View file @
3892b022
import
state
from
'
~/frequent_items/store/state
'
;
import
*
as
getters
from
'
~/frequent_items/store/getters
'
;
describe
(
'
Frequent Items Dropdown Store Getters
'
,
()
=>
{
let
mockedState
;
beforeEach
(()
=>
{
mockedState
=
state
();
});
describe
(
'
hasSearchQuery
'
,
()
=>
{
it
(
'
should return `true` when search query is present
'
,
()
=>
{
mockedState
.
searchQuery
=
'
test
'
;
expect
(
getters
.
hasSearchQuery
(
mockedState
)).
toBe
(
true
);
});
it
(
'
should return `false` when search query is empty
'
,
()
=>
{
mockedState
.
searchQuery
=
''
;
expect
(
getters
.
hasSearchQuery
(
mockedState
)).
toBe
(
false
);
});
});
});
spec/javascripts/frequent_items/store/mutations_spec.js
0 → 100644
View file @
3892b022
import
state
from
'
~/frequent_items/store/state
'
;
import
mutations
from
'
~/frequent_items/store/mutations
'
;
import
*
as
types
from
'
~/frequent_items/store/mutation_types
'
;
import
{
mockNamespace
,
mockStorageKey
,
mockFrequentProjects
,
mockSearchedProjects
,
mockProcessedSearchedProjects
,
mockSearchedGroups
,
mockProcessedSearchedGroups
,
}
from
'
../mock_data
'
;
describe
(
'
Frequent Items dropdown mutations
'
,
()
=>
{
let
stateCopy
;
beforeEach
(()
=>
{
stateCopy
=
state
();
});
describe
(
'
SET_NAMESPACE
'
,
()
=>
{
it
(
'
should set namespace
'
,
()
=>
{
mutations
[
types
.
SET_NAMESPACE
](
stateCopy
,
mockNamespace
);
expect
(
stateCopy
.
namespace
).
toEqual
(
mockNamespace
);
});
});
describe
(
'
SET_STORAGE_KEY
'
,
()
=>
{
it
(
'
should set storage key
'
,
()
=>
{
mutations
[
types
.
SET_STORAGE_KEY
](
stateCopy
,
mockStorageKey
);
expect
(
stateCopy
.
storageKey
).
toEqual
(
mockStorageKey
);
});
});
describe
(
'
SET_SEARCH_QUERY
'
,
()
=>
{
it
(
'
should set search query
'
,
()
=>
{
const
searchQuery
=
'
gitlab-ce
'
;
mutations
[
types
.
SET_SEARCH_QUERY
](
stateCopy
,
searchQuery
);
expect
(
stateCopy
.
searchQuery
).
toEqual
(
searchQuery
);
});
});
describe
(
'
REQUEST_FREQUENT_ITEMS
'
,
()
=>
{
it
(
'
should set view states when requesting frequent items
'
,
()
=>
{
mutations
[
types
.
REQUEST_FREQUENT_ITEMS
](
stateCopy
);
expect
(
stateCopy
.
isLoadingItems
).
toEqual
(
true
);
expect
(
stateCopy
.
hasSearchQuery
).
toEqual
(
false
);
});
});
describe
(
'
RECEIVE_FREQUENT_ITEMS_SUCCESS
'
,
()
=>
{
it
(
'
should set view states when receiving frequent items
'
,
()
=>
{
mutations
[
types
.
RECEIVE_FREQUENT_ITEMS_SUCCESS
](
stateCopy
,
mockFrequentProjects
);
expect
(
stateCopy
.
items
).
toEqual
(
mockFrequentProjects
);
expect
(
stateCopy
.
isLoadingItems
).
toEqual
(
false
);
expect
(
stateCopy
.
hasSearchQuery
).
toEqual
(
false
);
expect
(
stateCopy
.
isFetchFailed
).
toEqual
(
false
);
});
});
describe
(
'
RECEIVE_FREQUENT_ITEMS_ERROR
'
,
()
=>
{
it
(
'
should set items and view states when error occurs retrieving frequent items
'
,
()
=>
{
mutations
[
types
.
RECEIVE_FREQUENT_ITEMS_ERROR
](
stateCopy
);
expect
(
stateCopy
.
items
).
toEqual
([]);
expect
(
stateCopy
.
isLoadingItems
).
toEqual
(
false
);
expect
(
stateCopy
.
hasSearchQuery
).
toEqual
(
false
);
expect
(
stateCopy
.
isFetchFailed
).
toEqual
(
true
);
});
});
describe
(
'
REQUEST_SEARCHED_ITEMS
'
,
()
=>
{
it
(
'
should set view states when requesting searched items
'
,
()
=>
{
mutations
[
types
.
REQUEST_SEARCHED_ITEMS
](
stateCopy
);
expect
(
stateCopy
.
isLoadingItems
).
toEqual
(
true
);
expect
(
stateCopy
.
hasSearchQuery
).
toEqual
(
true
);
});
});
describe
(
'
RECEIVE_SEARCHED_ITEMS_SUCCESS
'
,
()
=>
{
it
(
'
should set items and view states when receiving searched items
'
,
()
=>
{
mutations
[
types
.
RECEIVE_SEARCHED_ITEMS_SUCCESS
](
stateCopy
,
mockSearchedProjects
);
expect
(
stateCopy
.
items
).
toEqual
(
mockProcessedSearchedProjects
);
expect
(
stateCopy
.
isLoadingItems
).
toEqual
(
false
);
expect
(
stateCopy
.
hasSearchQuery
).
toEqual
(
true
);
expect
(
stateCopy
.
isFetchFailed
).
toEqual
(
false
);
});
it
(
'
should also handle the different `full_name` key for namespace in groups payload
'
,
()
=>
{
mutations
[
types
.
RECEIVE_SEARCHED_ITEMS_SUCCESS
](
stateCopy
,
mockSearchedGroups
);
expect
(
stateCopy
.
items
).
toEqual
(
mockProcessedSearchedGroups
);
expect
(
stateCopy
.
isLoadingItems
).
toEqual
(
false
);
expect
(
stateCopy
.
hasSearchQuery
).
toEqual
(
true
);
expect
(
stateCopy
.
isFetchFailed
).
toEqual
(
false
);
});
});
describe
(
'
RECEIVE_SEARCHED_ITEMS_ERROR
'
,
()
=>
{
it
(
'
should set view states when error occurs retrieving searched items
'
,
()
=>
{
mutations
[
types
.
RECEIVE_SEARCHED_ITEMS_ERROR
](
stateCopy
);
expect
(
stateCopy
.
items
).
toEqual
([]);
expect
(
stateCopy
.
isLoadingItems
).
toEqual
(
false
);
expect
(
stateCopy
.
hasSearchQuery
).
toEqual
(
true
);
expect
(
stateCopy
.
isFetchFailed
).
toEqual
(
true
);
});
});
});
spec/javascripts/frequent_items/utils_spec.js
0 → 100644
View file @
3892b022
import
bp
from
'
~/breakpoints
'
;
import
{
isMobile
,
getTopFrequentItems
,
updateExistingFrequentItem
}
from
'
~/frequent_items/utils
'
;
import
{
HOUR_IN_MS
,
FREQUENT_ITEMS
}
from
'
~/frequent_items/constants
'
;
import
{
mockProject
,
unsortedFrequentItems
,
sortedFrequentItems
}
from
'
./mock_data
'
;
describe
(
'
Frequent Items utils spec
'
,
()
=>
{
describe
(
'
isMobile
'
,
()
=>
{
it
(
'
returns true when the screen is small
'
,
()
=>
{
spyOn
(
bp
,
'
getBreakpointSize
'
).
and
.
returnValue
(
'
sm
'
);
expect
(
isMobile
()).
toBe
(
true
);
});
it
(
'
returns true when the screen is extra-small
'
,
()
=>
{
spyOn
(
bp
,
'
getBreakpointSize
'
).
and
.
returnValue
(
'
xs
'
);
expect
(
isMobile
()).
toBe
(
true
);
});
it
(
'
returns false when the screen is larger than small
'
,
()
=>
{
spyOn
(
bp
,
'
getBreakpointSize
'
).
and
.
returnValue
(
'
md
'
);
expect
(
isMobile
()).
toBe
(
false
);
});
});
describe
(
'
getTopFrequentItems
'
,
()
=>
{
it
(
'
returns empty array if no items provided
'
,
()
=>
{
const
result
=
getTopFrequentItems
();
expect
(
result
.
length
).
toBe
(
0
);
});
it
(
'
returns correct amount of items for mobile
'
,
()
=>
{
spyOn
(
bp
,
'
getBreakpointSize
'
).
and
.
returnValue
(
'
sm
'
);
const
result
=
getTopFrequentItems
(
unsortedFrequentItems
);
expect
(
result
.
length
).
toBe
(
FREQUENT_ITEMS
.
LIST_COUNT_MOBILE
);
});
it
(
'
returns correct amount of items for desktop
'
,
()
=>
{
spyOn
(
bp
,
'
getBreakpointSize
'
).
and
.
returnValue
(
'
lg
'
);
const
result
=
getTopFrequentItems
(
unsortedFrequentItems
);
expect
(
result
.
length
).
toBe
(
FREQUENT_ITEMS
.
LIST_COUNT_DESKTOP
);
});
it
(
'
sorts frequent items in order of frequency and lastAccessedOn
'
,
()
=>
{
spyOn
(
bp
,
'
getBreakpointSize
'
).
and
.
returnValue
(
'
lg
'
);
const
result
=
getTopFrequentItems
(
unsortedFrequentItems
);
const
expectedResult
=
sortedFrequentItems
.
slice
(
0
,
FREQUENT_ITEMS
.
LIST_COUNT_DESKTOP
);
expect
(
result
).
toEqual
(
expectedResult
);
});
});
describe
(
'
updateExistingFrequentItem
'
,
()
=>
{
let
mockedProject
;
beforeEach
(()
=>
{
mockedProject
=
{
...
mockProject
,
frequency
:
1
,
lastAccessedOn
:
1497979281815
,
};
});
it
(
'
updates item if accessed over an hour ago
'
,
()
=>
{
const
newTimestamp
=
Date
.
now
()
+
HOUR_IN_MS
+
1
;
const
newItem
=
{
...
mockedProject
,
lastAccessedOn
:
newTimestamp
,
};
const
result
=
updateExistingFrequentItem
(
mockedProject
,
newItem
);
expect
(
result
.
frequency
).
toBe
(
mockedProject
.
frequency
+
1
);
});
it
(
'
does not update item if accessed within the hour
'
,
()
=>
{
const
newItem
=
{
...
mockedProject
,
lastAccessedOn
:
mockedProject
.
lastAccessedOn
+
HOUR_IN_MS
,
};
const
result
=
updateExistingFrequentItem
(
mockedProject
,
newItem
);
expect
(
result
.
frequency
).
toBe
(
mockedProject
.
frequency
);
});
});
});
spec/javascripts/projects_dropdown/components/app_spec.js
deleted
100644 → 0
View file @
b14b31b8
import
Vue
from
'
vue
'
;
import
bp
from
'
~/breakpoints
'
;
import
appComponent
from
'
~/projects_dropdown/components/app.vue
'
;
import
eventHub
from
'
~/projects_dropdown/event_hub
'
;
import
ProjectsStore
from
'
~/projects_dropdown/store/projects_store
'
;
import
ProjectsService
from
'
~/projects_dropdown/service/projects_service
'
;
import
mountComponent
from
'
spec/helpers/vue_mount_component_helper
'
;
import
{
currentSession
,
mockProject
,
mockRawProject
}
from
'
../mock_data
'
;
const
createComponent
=
()
=>
{
gon
.
api_version
=
currentSession
.
apiVersion
;
const
Component
=
Vue
.
extend
(
appComponent
);
const
store
=
new
ProjectsStore
();
const
service
=
new
ProjectsService
(
currentSession
.
username
);
return
mountComponent
(
Component
,
{
store
,
service
,
currentUserName
:
currentSession
.
username
,
currentProject
:
currentSession
.
project
,
});
};
const
returnServicePromise
=
(
data
,
failed
)
=>
new
Promise
((
resolve
,
reject
)
=>
{
if
(
failed
)
{
reject
(
data
);
}
else
{
resolve
({
json
()
{
return
data
;
},
});
}
});
describe
(
'
AppComponent
'
,
()
=>
{
describe
(
'
computed
'
,
()
=>
{
let
vm
;
beforeEach
(()
=>
{
vm
=
createComponent
();
});
afterEach
(()
=>
{
vm
.
$destroy
();
});
describe
(
'
frequentProjects
'
,
()
=>
{
it
(
'
should return list of frequently accessed projects from store
'
,
()
=>
{
expect
(
vm
.
frequentProjects
).
toBeDefined
();
expect
(
vm
.
frequentProjects
.
length
).
toBe
(
0
);
vm
.
store
.
setFrequentProjects
([
mockProject
]);
expect
(
vm
.
frequentProjects
).
toBeDefined
();
expect
(
vm
.
frequentProjects
.
length
).
toBe
(
1
);
});
});
describe
(
'
searchProjects
'
,
()
=>
{
it
(
'
should return list of frequently accessed projects from store
'
,
()
=>
{
expect
(
vm
.
searchProjects
).
toBeDefined
();
expect
(
vm
.
searchProjects
.
length
).
toBe
(
0
);
vm
.
store
.
setSearchedProjects
([
mockRawProject
]);
expect
(
vm
.
searchProjects
).
toBeDefined
();
expect
(
vm
.
searchProjects
.
length
).
toBe
(
1
);
});
});
});
describe
(
'
methods
'
,
()
=>
{
let
vm
;
beforeEach
(()
=>
{
vm
=
createComponent
();
});
afterEach
(()
=>
{
vm
.
$destroy
();
});
describe
(
'
toggleFrequentProjectsList
'
,
()
=>
{
it
(
'
should toggle props which control visibility of Frequent Projects list from state passed
'
,
()
=>
{
vm
.
toggleFrequentProjectsList
(
true
);
expect
(
vm
.
isLoadingProjects
).
toBeFalsy
();
expect
(
vm
.
isSearchListVisible
).
toBeFalsy
();
expect
(
vm
.
isFrequentsListVisible
).
toBeTruthy
();
vm
.
toggleFrequentProjectsList
(
false
);
expect
(
vm
.
isLoadingProjects
).
toBeTruthy
();
expect
(
vm
.
isSearchListVisible
).
toBeTruthy
();
expect
(
vm
.
isFrequentsListVisible
).
toBeFalsy
();
});
});
describe
(
'
toggleSearchProjectsList
'
,
()
=>
{
it
(
'
should toggle props which control visibility of Searched Projects list from state passed
'
,
()
=>
{
vm
.
toggleSearchProjectsList
(
true
);
expect
(
vm
.
isLoadingProjects
).
toBeFalsy
();
expect
(
vm
.
isFrequentsListVisible
).
toBeFalsy
();
expect
(
vm
.
isSearchListVisible
).
toBeTruthy
();
vm
.
toggleSearchProjectsList
(
false
);
expect
(
vm
.
isLoadingProjects
).
toBeTruthy
();
expect
(
vm
.
isFrequentsListVisible
).
toBeTruthy
();
expect
(
vm
.
isSearchListVisible
).
toBeFalsy
();
});
});
describe
(
'
toggleLoader
'
,
()
=>
{
it
(
'
should toggle props which control visibility of list loading animation from state passed
'
,
()
=>
{
vm
.
toggleLoader
(
true
);
expect
(
vm
.
isFrequentsListVisible
).
toBeFalsy
();
expect
(
vm
.
isSearchListVisible
).
toBeFalsy
();
expect
(
vm
.
isLoadingProjects
).
toBeTruthy
();
vm
.
toggleLoader
(
false
);
expect
(
vm
.
isFrequentsListVisible
).
toBeTruthy
();
expect
(
vm
.
isSearchListVisible
).
toBeTruthy
();
expect
(
vm
.
isLoadingProjects
).
toBeFalsy
();
});
});
describe
(
'
fetchFrequentProjects
'
,
()
=>
{
it
(
'
should set props for loading animation to `true` while frequent projects list is being loaded
'
,
()
=>
{
spyOn
(
vm
,
'
toggleLoader
'
);
vm
.
fetchFrequentProjects
();
expect
(
vm
.
isLocalStorageFailed
).
toBeFalsy
();
expect
(
vm
.
toggleLoader
).
toHaveBeenCalledWith
(
true
);
});
it
(
'
should set props for loading animation to `false` and props for frequent projects list to `true` once data is loaded
'
,
()
=>
{
const
mockData
=
[
mockProject
];
spyOn
(
vm
.
service
,
'
getFrequentProjects
'
).
and
.
returnValue
(
mockData
);
spyOn
(
vm
.
store
,
'
setFrequentProjects
'
);
spyOn
(
vm
,
'
toggleFrequentProjectsList
'
);
vm
.
fetchFrequentProjects
();
expect
(
vm
.
service
.
getFrequentProjects
).
toHaveBeenCalled
();
expect
(
vm
.
store
.
setFrequentProjects
).
toHaveBeenCalledWith
(
mockData
);
expect
(
vm
.
toggleFrequentProjectsList
).
toHaveBeenCalledWith
(
true
);
});
it
(
'
should set props for failure message to `true` when method fails to fetch frequent projects list
'
,
()
=>
{
spyOn
(
vm
.
service
,
'
getFrequentProjects
'
).
and
.
returnValue
(
null
);
spyOn
(
vm
.
store
,
'
setFrequentProjects
'
);
spyOn
(
vm
,
'
toggleFrequentProjectsList
'
);
expect
(
vm
.
isLocalStorageFailed
).
toBeFalsy
();
vm
.
fetchFrequentProjects
();
expect
(
vm
.
service
.
getFrequentProjects
).
toHaveBeenCalled
();
expect
(
vm
.
store
.
setFrequentProjects
).
toHaveBeenCalledWith
([]);
expect
(
vm
.
toggleFrequentProjectsList
).
toHaveBeenCalledWith
(
true
);
expect
(
vm
.
isLocalStorageFailed
).
toBeTruthy
();
});
it
(
'
should set props for search results list to `true` if search query was already made previously
'
,
()
=>
{
spyOn
(
bp
,
'
getBreakpointSize
'
).
and
.
returnValue
(
'
md
'
);
spyOn
(
vm
.
service
,
'
getFrequentProjects
'
);
spyOn
(
vm
,
'
toggleSearchProjectsList
'
);
vm
.
searchQuery
=
'
test
'
;
vm
.
fetchFrequentProjects
();
expect
(
vm
.
service
.
getFrequentProjects
).
not
.
toHaveBeenCalled
();
expect
(
vm
.
toggleSearchProjectsList
).
toHaveBeenCalledWith
(
true
);
});
it
(
'
should set props for frequent projects list to `true` if search query was already made but screen size is less than 768px
'
,
()
=>
{
spyOn
(
bp
,
'
getBreakpointSize
'
).
and
.
returnValue
(
'
sm
'
);
spyOn
(
vm
,
'
toggleSearchProjectsList
'
);
spyOn
(
vm
.
service
,
'
getFrequentProjects
'
);
vm
.
searchQuery
=
'
test
'
;
vm
.
fetchFrequentProjects
();
expect
(
vm
.
service
.
getFrequentProjects
).
toHaveBeenCalled
();
expect
(
vm
.
toggleSearchProjectsList
).
not
.
toHaveBeenCalled
();
});
});
describe
(
'
fetchSearchedProjects
'
,
()
=>
{
const
searchQuery
=
'
test
'
;
it
(
'
should perform search with provided search query
'
,
done
=>
{
const
mockData
=
[
mockRawProject
];
spyOn
(
vm
,
'
toggleLoader
'
);
spyOn
(
vm
,
'
toggleSearchProjectsList
'
);
spyOn
(
vm
.
service
,
'
getSearchedProjects
'
).
and
.
returnValue
(
returnServicePromise
(
mockData
));
spyOn
(
vm
.
store
,
'
setSearchedProjects
'
);
vm
.
fetchSearchedProjects
(
searchQuery
);
setTimeout
(()
=>
{
expect
(
vm
.
searchQuery
).
toBe
(
searchQuery
);
expect
(
vm
.
toggleLoader
).
toHaveBeenCalledWith
(
true
);
expect
(
vm
.
service
.
getSearchedProjects
).
toHaveBeenCalledWith
(
searchQuery
);
expect
(
vm
.
toggleSearchProjectsList
).
toHaveBeenCalledWith
(
true
);
expect
(
vm
.
store
.
setSearchedProjects
).
toHaveBeenCalledWith
(
mockData
);
done
();
},
0
);
});
it
(
'
should update props for showing search failure
'
,
done
=>
{
spyOn
(
vm
,
'
toggleSearchProjectsList
'
);
spyOn
(
vm
.
service
,
'
getSearchedProjects
'
).
and
.
returnValue
(
returnServicePromise
({},
true
));
vm
.
fetchSearchedProjects
(
searchQuery
);
setTimeout
(()
=>
{
expect
(
vm
.
searchQuery
).
toBe
(
searchQuery
);
expect
(
vm
.
service
.
getSearchedProjects
).
toHaveBeenCalledWith
(
searchQuery
);
expect
(
vm
.
isSearchFailed
).
toBeTruthy
();
expect
(
vm
.
toggleSearchProjectsList
).
toHaveBeenCalledWith
(
true
);
done
();
},
0
);
});
});
describe
(
'
logCurrentProjectAccess
'
,
()
=>
{
it
(
'
should log current project access via service
'
,
done
=>
{
spyOn
(
vm
.
service
,
'
logProjectAccess
'
);
vm
.
currentProject
=
mockProject
;
vm
.
logCurrentProjectAccess
();
setTimeout
(()
=>
{
expect
(
vm
.
service
.
logProjectAccess
).
toHaveBeenCalledWith
(
mockProject
);
done
();
},
1
);
});
});
describe
(
'
handleSearchClear
'
,
()
=>
{
it
(
'
should show frequent projects list when search input is cleared
'
,
()
=>
{
spyOn
(
vm
.
store
,
'
clearSearchedProjects
'
);
spyOn
(
vm
,
'
toggleFrequentProjectsList
'
);
vm
.
handleSearchClear
();
expect
(
vm
.
toggleFrequentProjectsList
).
toHaveBeenCalledWith
(
true
);
expect
(
vm
.
store
.
clearSearchedProjects
).
toHaveBeenCalled
();
expect
(
vm
.
searchQuery
).
toBe
(
''
);
});
});
describe
(
'
handleSearchFailure
'
,
()
=>
{
it
(
'
should show failure message within dropdown
'
,
()
=>
{
spyOn
(
vm
,
'
toggleSearchProjectsList
'
);
vm
.
handleSearchFailure
();
expect
(
vm
.
toggleSearchProjectsList
).
toHaveBeenCalledWith
(
true
);
expect
(
vm
.
isSearchFailed
).
toBeTruthy
();
});
});
});
describe
(
'
created
'
,
()
=>
{
it
(
'
should bind event listeners on eventHub
'
,
done
=>
{
spyOn
(
eventHub
,
'
$on
'
);
createComponent
().
$mount
();
Vue
.
nextTick
(()
=>
{
expect
(
eventHub
.
$on
).
toHaveBeenCalledWith
(
'
dropdownOpen
'
,
jasmine
.
any
(
Function
));
expect
(
eventHub
.
$on
).
toHaveBeenCalledWith
(
'
searchProjects
'
,
jasmine
.
any
(
Function
));
expect
(
eventHub
.
$on
).
toHaveBeenCalledWith
(
'
searchCleared
'
,
jasmine
.
any
(
Function
));
expect
(
eventHub
.
$on
).
toHaveBeenCalledWith
(
'
searchFailed
'
,
jasmine
.
any
(
Function
));
done
();
});
});
});
describe
(
'
beforeDestroy
'
,
()
=>
{
it
(
'
should unbind event listeners on eventHub
'
,
done
=>
{
const
vm
=
createComponent
();
spyOn
(
eventHub
,
'
$off
'
);
vm
.
$mount
();
vm
.
$destroy
();
Vue
.
nextTick
(()
=>
{
expect
(
eventHub
.
$off
).
toHaveBeenCalledWith
(
'
dropdownOpen
'
,
jasmine
.
any
(
Function
));
expect
(
eventHub
.
$off
).
toHaveBeenCalledWith
(
'
searchProjects
'
,
jasmine
.
any
(
Function
));
expect
(
eventHub
.
$off
).
toHaveBeenCalledWith
(
'
searchCleared
'
,
jasmine
.
any
(
Function
));
expect
(
eventHub
.
$off
).
toHaveBeenCalledWith
(
'
searchFailed
'
,
jasmine
.
any
(
Function
));
done
();
});
});
});
describe
(
'
template
'
,
()
=>
{
let
vm
;
beforeEach
(()
=>
{
vm
=
createComponent
();
});
afterEach
(()
=>
{
vm
.
$destroy
();
});
it
(
'
should render search input
'
,
()
=>
{
expect
(
vm
.
$el
.
querySelector
(
'
.search-input-container
'
)).
toBeDefined
();
});
it
(
'
should render loading animation
'
,
done
=>
{
vm
.
toggleLoader
(
true
);
Vue
.
nextTick
(()
=>
{
const
loadingEl
=
vm
.
$el
.
querySelector
(
'
.loading-animation
'
);
expect
(
loadingEl
).
toBeDefined
();
expect
(
loadingEl
.
classList
.
contains
(
'
prepend-top-20
'
)).
toBeTruthy
();
expect
(
loadingEl
.
querySelector
(
'
i
'
).
getAttribute
(
'
aria-label
'
)).
toBe
(
'
Loading projects
'
);
done
();
});
});
it
(
'
should render frequent projects list header
'
,
done
=>
{
vm
.
toggleFrequentProjectsList
(
true
);
Vue
.
nextTick
(()
=>
{
const
sectionHeaderEl
=
vm
.
$el
.
querySelector
(
'
.section-header
'
);
expect
(
sectionHeaderEl
).
toBeDefined
();
expect
(
sectionHeaderEl
.
innerText
.
trim
()).
toBe
(
'
Frequently visited
'
);
done
();
});
});
it
(
'
should render frequent projects list
'
,
done
=>
{
vm
.
toggleFrequentProjectsList
(
true
);
Vue
.
nextTick
(()
=>
{
expect
(
vm
.
$el
.
querySelector
(
'
.projects-list-frequent-container
'
)).
toBeDefined
();
done
();
});
});
it
(
'
should render searched projects list
'
,
done
=>
{
vm
.
toggleSearchProjectsList
(
true
);
Vue
.
nextTick
(()
=>
{
expect
(
vm
.
$el
.
querySelector
(
'
.section-header
'
)).
toBe
(
null
);
expect
(
vm
.
$el
.
querySelector
(
'
.projects-list-search-container
'
)).
toBeDefined
();
done
();
});
});
});
});
spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js
deleted
100644 → 0
View file @
b14b31b8
import
Vue
from
'
vue
'
;
import
projectsListFrequentComponent
from
'
~/projects_dropdown/components/projects_list_frequent.vue
'
;
import
mountComponent
from
'
spec/helpers/vue_mount_component_helper
'
;
import
{
mockFrequents
}
from
'
../mock_data
'
;
const
createComponent
=
()
=>
{
const
Component
=
Vue
.
extend
(
projectsListFrequentComponent
);
return
mountComponent
(
Component
,
{
projects
:
mockFrequents
,
localStorageFailed
:
false
,
});
};
describe
(
'
ProjectsListFrequentComponent
'
,
()
=>
{
let
vm
;
beforeEach
(()
=>
{
vm
=
createComponent
();
});
afterEach
(()
=>
{
vm
.
$destroy
();
});
describe
(
'
computed
'
,
()
=>
{
describe
(
'
isListEmpty
'
,
()
=>
{
it
(
'
should return `true` or `false` representing whether if `projects` is empty of not
'
,
()
=>
{
vm
.
projects
=
[];
expect
(
vm
.
isListEmpty
).
toBeTruthy
();
vm
.
projects
=
mockFrequents
;
expect
(
vm
.
isListEmpty
).
toBeFalsy
();
});
});
describe
(
'
listEmptyMessage
'
,
()
=>
{
it
(
'
should return appropriate empty list message based on value of `localStorageFailed` prop
'
,
()
=>
{
vm
.
localStorageFailed
=
true
;
expect
(
vm
.
listEmptyMessage
).
toBe
(
'
This feature requires browser localStorage support
'
);
vm
.
localStorageFailed
=
false
;
expect
(
vm
.
listEmptyMessage
).
toBe
(
'
Projects you visit often will appear here
'
);
});
});
});
describe
(
'
template
'
,
()
=>
{
it
(
'
should render component element with list of projects
'
,
(
done
)
=>
{
vm
.
projects
=
mockFrequents
;
Vue
.
nextTick
(()
=>
{
expect
(
vm
.
$el
.
classList
.
contains
(
'
projects-list-frequent-container
'
)).
toBeTruthy
();
expect
(
vm
.
$el
.
querySelectorAll
(
'
ul.list-unstyled
'
).
length
).
toBe
(
1
);
expect
(
vm
.
$el
.
querySelectorAll
(
'
li.projects-list-item-container
'
).
length
).
toBe
(
5
);
done
();
});
});
it
(
'
should render component element with empty message
'
,
(
done
)
=>
{
vm
.
projects
=
[];
Vue
.
nextTick
(()
=>
{
expect
(
vm
.
$el
.
querySelectorAll
(
'
li.section-empty
'
).
length
).
toBe
(
1
);
expect
(
vm
.
$el
.
querySelectorAll
(
'
li.projects-list-item-container
'
).
length
).
toBe
(
0
);
done
();
});
});
});
});
spec/javascripts/projects_dropdown/service/projects_service_spec.js
deleted
100644 → 0
View file @
b14b31b8
import
Vue
from
'
vue
'
;
import
VueResource
from
'
vue-resource
'
;
import
bp
from
'
~/breakpoints
'
;
import
ProjectsService
from
'
~/projects_dropdown/service/projects_service
'
;
import
{
FREQUENT_PROJECTS
}
from
'
~/projects_dropdown/constants
'
;
import
{
currentSession
,
unsortedFrequents
,
sortedFrequents
}
from
'
../mock_data
'
;
Vue
.
use
(
VueResource
);
FREQUENT_PROJECTS
.
MAX_COUNT
=
3
;
describe
(
'
ProjectsService
'
,
()
=>
{
let
service
;
beforeEach
(()
=>
{
gon
.
api_version
=
currentSession
.
apiVersion
;
gon
.
current_user_id
=
1
;
service
=
new
ProjectsService
(
currentSession
.
username
);
});
describe
(
'
contructor
'
,
()
=>
{
it
(
'
should initialize default properties of class
'
,
()
=>
{
expect
(
service
.
isLocalStorageAvailable
).
toBeTruthy
();
expect
(
service
.
currentUserName
).
toBe
(
currentSession
.
username
);
expect
(
service
.
storageKey
).
toBe
(
currentSession
.
storageKey
);
expect
(
service
.
projectsPath
).
toBeDefined
();
});
});
describe
(
'
getSearchedProjects
'
,
()
=>
{
it
(
'
should return promise from VueResource HTTP GET
'
,
()
=>
{
spyOn
(
service
.
projectsPath
,
'
get
'
).
and
.
stub
();
const
searchQuery
=
'
lab
'
;
const
queryParams
=
{
simple
:
true
,
per_page
:
20
,
membership
:
true
,
order_by
:
'
last_activity_at
'
,
search
:
searchQuery
,
};
service
.
getSearchedProjects
(
searchQuery
);
expect
(
service
.
projectsPath
.
get
).
toHaveBeenCalledWith
(
queryParams
);
});
});
describe
(
'
logProjectAccess
'
,
()
=>
{
let
storage
;
beforeEach
(()
=>
{
storage
=
{};
spyOn
(
window
.
localStorage
,
'
setItem
'
).
and
.
callFake
((
storageKey
,
value
)
=>
{
storage
[
storageKey
]
=
value
;
});
spyOn
(
window
.
localStorage
,
'
getItem
'
).
and
.
callFake
((
storageKey
)
=>
{
if
(
storage
[
storageKey
])
{
return
storage
[
storageKey
];
}
return
null
;
});
});
it
(
'
should create a project store if it does not exist and adds a project
'
,
()
=>
{
service
.
logProjectAccess
(
currentSession
.
project
);
const
projects
=
JSON
.
parse
(
storage
[
currentSession
.
storageKey
]);
expect
(
projects
.
length
).
toBe
(
1
);
expect
(
projects
[
0
].
frequency
).
toBe
(
1
);
expect
(
projects
[
0
].
lastAccessedOn
).
toBeDefined
();
});
it
(
'
should prevent inserting same report multiple times into store
'
,
()
=>
{
service
.
logProjectAccess
(
currentSession
.
project
);
service
.
logProjectAccess
(
currentSession
.
project
);
const
projects
=
JSON
.
parse
(
storage
[
currentSession
.
storageKey
]);
expect
(
projects
.
length
).
toBe
(
1
);
});
it
(
'
should increase frequency of report if it was logged multiple times over the course of an hour
'
,
()
=>
{
let
projects
;
spyOn
(
Math
,
'
abs
'
).
and
.
returnValue
(
3600001
);
// this will lead to `diff` > 1;
service
.
logProjectAccess
(
currentSession
.
project
);
projects
=
JSON
.
parse
(
storage
[
currentSession
.
storageKey
]);
expect
(
projects
[
0
].
frequency
).
toBe
(
1
);
service
.
logProjectAccess
(
currentSession
.
project
);
projects
=
JSON
.
parse
(
storage
[
currentSession
.
storageKey
]);
expect
(
projects
[
0
].
frequency
).
toBe
(
2
);
expect
(
projects
[
0
].
lastAccessedOn
).
not
.
toBe
(
currentSession
.
project
.
lastAccessedOn
);
});
it
(
'
should always update project metadata
'
,
()
=>
{
let
projects
;
const
oldProject
=
{
...
currentSession
.
project
,
};
const
newProject
=
{
...
currentSession
.
project
,
name
:
'
New Name
'
,
avatarUrl
:
'
new/avatar.png
'
,
namespace
:
'
New / Namespace
'
,
webUrl
:
'
http://localhost/new/web/url
'
,
};
service
.
logProjectAccess
(
oldProject
);
projects
=
JSON
.
parse
(
storage
[
currentSession
.
storageKey
]);
expect
(
projects
[
0
].
name
).
toBe
(
oldProject
.
name
);
expect
(
projects
[
0
].
avatarUrl
).
toBe
(
oldProject
.
avatarUrl
);
expect
(
projects
[
0
].
namespace
).
toBe
(
oldProject
.
namespace
);
expect
(
projects
[
0
].
webUrl
).
toBe
(
oldProject
.
webUrl
);
service
.
logProjectAccess
(
newProject
);
projects
=
JSON
.
parse
(
storage
[
currentSession
.
storageKey
]);
expect
(
projects
[
0
].
name
).
toBe
(
newProject
.
name
);
expect
(
projects
[
0
].
avatarUrl
).
toBe
(
newProject
.
avatarUrl
);
expect
(
projects
[
0
].
namespace
).
toBe
(
newProject
.
namespace
);
expect
(
projects
[
0
].
webUrl
).
toBe
(
newProject
.
webUrl
);
});
it
(
'
should not add more than 20 projects in store
'
,
()
=>
{
for
(
let
i
=
1
;
i
<=
5
;
i
+=
1
)
{
const
project
=
Object
.
assign
(
currentSession
.
project
,
{
id
:
i
});
service
.
logProjectAccess
(
project
);
}
const
projects
=
JSON
.
parse
(
storage
[
currentSession
.
storageKey
]);
expect
(
projects
.
length
).
toBe
(
3
);
});
});
describe
(
'
getTopFrequentProjects
'
,
()
=>
{
let
storage
=
{};
beforeEach
(()
=>
{
storage
[
currentSession
.
storageKey
]
=
JSON
.
stringify
(
unsortedFrequents
);
spyOn
(
window
.
localStorage
,
'
getItem
'
).
and
.
callFake
((
storageKey
)
=>
{
if
(
storage
[
storageKey
])
{
return
storage
[
storageKey
];
}
return
null
;
});
});
it
(
'
should return top 5 frequently accessed projects for desktop screens
'
,
()
=>
{
spyOn
(
bp
,
'
getBreakpointSize
'
).
and
.
returnValue
(
'
md
'
);
const
frequentProjects
=
service
.
getTopFrequentProjects
();
expect
(
frequentProjects
.
length
).
toBe
(
5
);
frequentProjects
.
forEach
((
project
,
index
)
=>
{
expect
(
project
.
id
).
toBe
(
sortedFrequents
[
index
].
id
);
});
});
it
(
'
should return top 3 frequently accessed projects for mobile screens
'
,
()
=>
{
spyOn
(
bp
,
'
getBreakpointSize
'
).
and
.
returnValue
(
'
sm
'
);
const
frequentProjects
=
service
.
getTopFrequentProjects
();
expect
(
frequentProjects
.
length
).
toBe
(
3
);
frequentProjects
.
forEach
((
project
,
index
)
=>
{
expect
(
project
.
id
).
toBe
(
sortedFrequents
[
index
].
id
);
});
});
it
(
'
should return empty array if there are no projects available in store
'
,
()
=>
{
storage
=
{};
expect
(
service
.
getTopFrequentProjects
().
length
).
toBe
(
0
);
});
});
});
spec/javascripts/projects_dropdown/store/projects_store_spec.js
deleted
100644 → 0
View file @
b14b31b8
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
);
});
});
});
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment