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
1
Merge Requests
1
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
nexedi
gitlab-ce
Commits
01e55160
Commit
01e55160
authored
Jul 06, 2018
by
Dennis Tang
Committed by
Phil Hughes
Jul 06, 2018
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
port 36234-nav-add-groups-dropdown to EE
parent
40c38130
Changes
47
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
47 changed files
with
1898 additions
and
1369 deletions
+1898
-1369
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
ee/spec/features/admin/admin_audit_logs_spec.rb
ee/spec/features/admin/admin_audit_logs_spec.rb
+0
-2
ee/spec/features/dashboards/group_dashboard_with_external_authorization_service_spec.rb
...oup_dashboard_with_external_authorization_service_spec.rb
+4
-4
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 @
01e55160
<
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 @
01e55160
<
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 @
01e55160
<
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 @
01e55160
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 @
01e55160
<
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 @
01e55160
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 @
01e55160
File moved
app/assets/javascripts/frequent_items/index.js
0 → 100644
View file @
01e55160
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 @
01e55160
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 @
01e55160
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 @
01e55160
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 @
01e55160
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 @
01e55160
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 @
01e55160
export
default
()
=>
({
namespace
:
''
,
storageKey
:
''
,
searchQuery
:
''
,
isLoadingItems
:
false
,
isFetchFailed
:
false
,
items
:
[],
});
app/assets/javascripts/frequent_items/utils.js
0 → 100644
View file @
01e55160
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 @
01e55160
...
...
@@ -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 @
40c38130
<
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 @
40c38130
<
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 @
40c38130
<
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 @
40c38130
<
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 @
40c38130
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 @
40c38130
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 @
40c38130
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 @
40c38130
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 @
01e55160
...
...
@@ -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 @
01e55160
...
...
@@ -29,15 +29,21 @@
.navbar-sub-nav
,
.navbar-nav
{
>
li
{
>
a
:hover
,
>
a
:focus
{
background-color
:
rgba
(
$search-and-nav-links
,
0
.2
);
>
a
,
>
button
{
&
:hover
,
&
:focus
{
background-color
:
rgba
(
$search-and-nav-links
,
0
.2
);
}
}
&
.active
>
a
,
&
.dropdown.show
>
a
{
color
:
$nav-svg-color
;
background-color
:
$color-alternate
;
&
.active
,
&
.dropdown.show
{
>
a
,
>
button
{
color
:
$nav-svg-color
;
background-color
:
$color-alternate
;
}
}
&
.line-separator
{
...
...
@@ -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 @
01e55160
...
...
@@ -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 @
01e55160
%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 @
01e55160
-
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 @
01e55160
-
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 @
01e55160
---
title
:
Add dropdown to Groups link in top bar
merge_request
:
18280
author
:
type
:
added
ee/spec/features/admin/admin_audit_logs_spec.rb
View file @
01e55160
...
...
@@ -71,7 +71,6 @@ describe 'Admin::AuditLogs', :js do
it
'filters by group'
do
filter_by_type
(
'Group Events'
)
click_button
'Group'
find
(
'.group-item-select'
).
click
wait_for_requests
find
(
'.select2-results'
).
click
...
...
@@ -95,7 +94,6 @@ describe 'Admin::AuditLogs', :js do
it
'filters by project'
do
filter_by_type
(
'Project Events'
)
click_button
'Project'
find
(
'.project-item-select'
).
click
wait_for_requests
find
(
'.select2-results'
).
click
...
...
ee/spec/features/dashboards/group_dashboard_with_external_authorization_service_spec.rb
View file @
01e55160
...
...
@@ -14,8 +14,8 @@ describe 'The group dashboard' do
visit
dashboard_groups_path
within
(
'.navbar'
)
do
expect
(
page
).
to
have_
link
(
'Projects'
)
expect
(
page
).
to
have_
link
(
'Groups'
)
expect
(
page
).
to
have_
button
(
'Projects'
)
expect
(
page
).
to
have_
button
(
'Groups'
)
expect
(
page
).
to
have_link
(
'Activity'
)
expect
(
page
).
to
have_link
(
'Milestones'
)
expect
(
page
).
to
have_link
(
'Snippets'
)
...
...
@@ -27,8 +27,8 @@ describe 'The group dashboard' do
visit
dashboard_groups_path
within
(
'.navbar'
)
do
expect
(
page
).
to
have_
link
(
'Projects'
)
expect
(
page
).
to
have_
link
(
'Groups'
)
expect
(
page
).
to
have_
button
(
'Projects'
)
expect
(
page
).
to
have_
button
(
'Groups'
)
expect
(
page
).
not_to
have_link
(
'Activity'
)
expect
(
page
).
not_to
have_link
(
'Milestones'
)
expect
(
page
).
to
have_link
(
'Snippets'
)
...
...
qa/qa/page/menu/main.rb
View file @
01e55160
...
...
@@ -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 @
01e55160
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 @
01e55160
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 @
01e55160
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 @
01e55160
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
'
,
()
=>
{
let
vm
;
describe
(
'
FrequentItemsSearchInputComponent
'
,
()
=>
{
let
vm
;
beforeEach
(()
=>
{
vm
=
createComponent
();
});
beforeEach
(()
=>
{
vm
=
createComponent
();
});
afterEach
(()
=>
{
vm
.
$destroy
();
});
afterEach
(()
=>
{
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 @
01e55160
export
const
currentSession
=
{
username
:
'
root
'
,
storageKey
:
'
root/frequent-projects
'
,
apiVersion
:
'
v4
'
,
project
:
{
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
:
'
SampleGroup / Dummy-Project
'
,
webUrl
:
`
${
gl
.
TEST_HOST
}
/samplegroup/dummy-project`
,
avatarUrl
:
null
,
lastAccessedOn
:
Date
.
now
(),
},
},
};
export
const
mockNamespace
=
'
projects
'
;
export
const
mockStorageKey
=
'
test-user/frequent-projects
'
;
export
const
mockGroup
=
{
id
:
1
,
name
:
'
Sub451
'
,
namespace
:
'
Commit451 / Sub451
'
,
webUrl
:
`
${
gl
.
TEST_HOST
}
/Commit451/Sub451`
,
avatarUrl
:
null
,
};
export
const
mockRawGroup
=
{
id
:
1
,
name
:
'
Sub451
'
,
full_name
:
'
Commit451 / Sub451
'
,
web_url
:
`
${
gl
.
TEST_HOST
}
/Commit451/Sub451`
,
avatar_url
:
null
,
};
export
const
mockFrequentGroups
=
[
{
id
:
3
,
name
:
'
Subgroup451
'
,
full_name
:
'
Commit451 / Subgroup451
'
,
webUrl
:
'
/Commit451/Subgroup451
'
,
avatarUrl
:
null
,
frequency
:
7
,
lastAccessedOn
:
1497979281815
,
},
{
id
:
1
,
name
:
'
dummy-project
'
,
namespace
:
'
SamepleGroup / Dummy-Project
'
,
webUrl
:
'
http://127.0.0.1/samplegroup/dummy-project
'
,
name
:
'
Commit451
'
,
full_name
:
'
Commit451
'
,
webUrl
:
'
/Commit451
'
,
avatarUrl
:
null
,
lastAccessedOn
:
Date
.
now
(),
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 @
01e55160
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 @
01e55160
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 @
01e55160
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 @
01e55160
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 @
40c38130
This diff is collapsed.
Click to expand it.
spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js
deleted
100644 → 0
View file @
40c38130
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 @
40c38130
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 @
40c38130
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