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
ddd4cc64
Commit
ddd4cc64
authored
Dec 08, 2018
by
Tim Zallmann
Committed by
Clement Ho
Dec 08, 2018
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Resolve "Extended user centric tooltips"
parent
a89b5269
Changes
28
Hide whitespace changes
Inline
Side-by-side
Showing
28 changed files
with
678 additions
and
15 deletions
+678
-15
app/assets/javascripts/api.js
app/assets/javascripts/api.js
+18
-2
app/assets/javascripts/behaviors/markdown/render_gfm.js
app/assets/javascripts/behaviors/markdown/render_gfm.js
+2
-0
app/assets/javascripts/lib/utils/users_cache.js
app/assets/javascripts/lib/utils/users_cache.js
+28
-0
app/assets/javascripts/main.js
app/assets/javascripts/main.js
+2
-0
app/assets/javascripts/notes/components/note_edited_text.vue
app/assets/javascripts/notes/components/note_edited_text.vue
+4
-1
app/assets/javascripts/notes/components/note_header.vue
app/assets/javascripts/notes/components/note_header.vue
+8
-1
app/assets/javascripts/notes/components/notes_app.vue
app/assets/javascripts/notes/components/notes_app.vue
+5
-1
app/assets/javascripts/user_popovers.js
app/assets/javascripts/user_popovers.js
+107
-0
app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
...s/vue_shared/components/user_avatar/user_avatar_image.vue
+2
-1
app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
...ripts/vue_shared/components/user_popover/user_popover.vue
+104
-0
app/assets/stylesheets/application.scss
app/assets/stylesheets/application.scss
+5
-0
app/assets/stylesheets/components/popover.scss
app/assets/stylesheets/components/popover.scss
+9
-0
app/assets/stylesheets/framework/variables.scss
app/assets/stylesheets/framework/variables.scss
+1
-0
app/assets/stylesheets/framework/variables_overrides.scss
app/assets/stylesheets/framework/variables_overrides.scss
+5
-0
app/helpers/issuables_helper.rb
app/helpers/issuables_helper.rb
+1
-1
app/helpers/projects_helper.rb
app/helpers/projects_helper.rb
+7
-1
changelogs/unreleased/50157-extended-user-centric-tooltips.yml
...elogs/unreleased/50157-extended-user-centric-tooltips.yml
+5
-0
lib/banzai/filter/user_reference_filter.rb
lib/banzai/filter/user_reference_filter.rb
+1
-1
locale/gitlab.pot
locale/gitlab.pot
+3
-0
spec/javascripts/api_spec.js
spec/javascripts/api_spec.js
+34
-0
spec/javascripts/lib/utils/users_cache_spec.js
spec/javascripts/lib/utils/users_cache_spec.js
+109
-1
spec/javascripts/notes/components/note_edited_text_spec.js
spec/javascripts/notes/components/note_edited_text_spec.js
+1
-1
spec/javascripts/notes/components/note_header_spec.js
spec/javascripts/notes/components/note_header_spec.js
+3
-0
spec/javascripts/user_popovers_spec.js
spec/javascripts/user_popovers_spec.js
+66
-0
spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js
...e_shared/components/user_avatar/user_avatar_image_spec.js
+13
-0
spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js
...ue_shared/components/user_avatar/user_avatar_link_spec.js
+1
-3
spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js
...s/vue_shared/components/user_popover/user_popover_spec.js
+133
-0
spec/lib/banzai/filter/user_reference_filter_spec.rb
spec/lib/banzai/filter/user_reference_filter_spec.rb
+1
-1
No files found.
app/assets/javascripts/api.js
View file @
ddd4cc64
...
...
@@ -21,7 +21,9 @@ const Api = {
projectTemplatePath
:
'
/api/:version/projects/:id/templates/:type/:key
'
,
projectTemplatesPath
:
'
/api/:version/projects/:id/templates/:type
'
,
usersPath
:
'
/api/:version/users.json
'
,
userStatusPath
:
'
/api/:version/user/status
'
,
userPath
:
'
/api/:version/users/:id
'
,
userStatusPath
:
'
/api/:version/users/:id/status
'
,
userPostStatusPath
:
'
/api/:version/user/status
'
,
commitPath
:
'
/api/:version/projects/:id/repository/commits
'
,
commitPipelinesPath
:
'
/:project_id/commit/:sha/pipelines
'
,
branchSinglePath
:
'
/api/:version/projects/:id/repository/branches/:branch
'
,
...
...
@@ -254,6 +256,20 @@ const Api = {
});
},
user
(
id
,
options
)
{
const
url
=
Api
.
buildUrl
(
this
.
userPath
).
replace
(
'
:id
'
,
encodeURIComponent
(
id
));
return
axios
.
get
(
url
,
{
params
:
options
,
});
},
userStatus
(
id
,
options
)
{
const
url
=
Api
.
buildUrl
(
this
.
userStatusPath
).
replace
(
'
:id
'
,
encodeURIComponent
(
id
));
return
axios
.
get
(
url
,
{
params
:
options
,
});
},
branches
(
id
,
query
=
''
,
options
=
{})
{
const
url
=
Api
.
buildUrl
(
this
.
createBranchPath
).
replace
(
'
:id
'
,
encodeURIComponent
(
id
));
...
...
@@ -276,7 +292,7 @@ const Api = {
},
postUserStatus
({
emoji
,
message
})
{
const
url
=
Api
.
buildUrl
(
this
.
userStatusPath
);
const
url
=
Api
.
buildUrl
(
this
.
user
Post
StatusPath
);
return
axios
.
put
(
url
,
{
emoji
,
...
...
app/assets/javascripts/behaviors/markdown/render_gfm.js
View file @
ddd4cc64
...
...
@@ -3,6 +3,7 @@ import syntaxHighlight from '~/syntax_highlight';
import
renderMath
from
'
./render_math
'
;
import
renderMermaid
from
'
./render_mermaid
'
;
import
highlightCurrentUser
from
'
./highlight_current_user
'
;
import
initUserPopovers
from
'
../../user_popovers
'
;
// Render GitLab flavoured Markdown
//
...
...
@@ -13,6 +14,7 @@ $.fn.renderGFM = function renderGFM() {
renderMath
(
this
.
find
(
'
.js-render-math
'
));
renderMermaid
(
this
.
find
(
'
.js-render-mermaid
'
));
highlightCurrentUser
(
this
.
find
(
'
.gfm-project_member
'
).
get
());
initUserPopovers
(
this
.
find
(
'
.gfm-project_member
'
).
get
());
return
this
;
};
...
...
app/assets/javascripts/lib/utils/users_cache.js
View file @
ddd4cc64
...
...
@@ -22,6 +22,34 @@ class UsersCache extends Cache {
});
// missing catch is intentional, error handling depends on use case
}
retrieveById
(
userId
)
{
if
(
this
.
hasData
(
userId
)
&&
this
.
get
(
userId
).
username
)
{
return
Promise
.
resolve
(
this
.
get
(
userId
));
}
return
Api
.
user
(
userId
).
then
(({
data
})
=>
{
this
.
internalStorage
[
userId
]
=
data
;
return
data
;
});
// missing catch is intentional, error handling depends on use case
}
retrieveStatusById
(
userId
)
{
if
(
this
.
hasData
(
userId
)
&&
this
.
get
(
userId
).
status
)
{
return
Promise
.
resolve
(
this
.
get
(
userId
).
status
);
}
return
Api
.
userStatus
(
userId
).
then
(({
data
})
=>
{
if
(
!
this
.
hasData
(
userId
))
{
this
.
internalStorage
[
userId
]
=
{};
}
this
.
internalStorage
[
userId
].
status
=
data
;
return
data
;
});
// missing catch is intentional, error handling depends on use case
}
}
export
default
new
UsersCache
();
app/assets/javascripts/main.js
View file @
ddd4cc64
...
...
@@ -30,6 +30,7 @@ import initUsagePingConsent from './usage_ping_consent';
import
initPerformanceBar
from
'
./performance_bar
'
;
import
initSearchAutocomplete
from
'
./search_autocomplete
'
;
import
GlFieldErrors
from
'
./gl_field_errors
'
;
import
initUserPopovers
from
'
./user_popovers
'
;
// expose jQuery as global (TODO: remove these)
window
.
jQuery
=
jQuery
;
...
...
@@ -78,6 +79,7 @@ document.addEventListener('DOMContentLoaded', () => {
initTodoToggle
();
initLogoAnimation
();
initUsagePingConsent
();
initUserPopovers
();
if
(
document
.
querySelector
(
'
.search
'
))
initSearchAutocomplete
();
if
(
document
.
querySelector
(
'
#js-peek
'
))
initPerformanceBar
({
container
:
'
#js-peek
'
});
...
...
app/assets/javascripts/notes/components/note_edited_text.vue
View file @
ddd4cc64
...
...
@@ -39,7 +39,10 @@ export default {
<div
:class=
"className"
>
{{
actionText
}}
<template
v-if=
"editedBy"
>
by
<a
:href=
"editedBy.path"
class=
"js-vue-author author-link"
>
{{
editedBy
.
name
}}
</a>
by
<a
:href=
"editedBy.path"
:data-user-id=
"editedBy.id"
class=
"js-user-link author-link"
>
{{
editedBy
.
name
}}
</a>
</
template
>
{{ actionDetailText }}
<time-ago-tooltip
:time=
"editedAt"
tooltip-placement=
"bottom"
/>
...
...
app/assets/javascripts/notes/components/note_header.vue
View file @
ddd4cc64
...
...
@@ -73,7 +73,14 @@ export default {
{{
__
(
'
Toggle discussion
'
)
}}
</button>
</div>
<a
v-if=
"hasAuthor"
v-once
:href=
"author.path"
>
<a
v-if=
"hasAuthor"
v-once
:href=
"author.path"
class=
"js-user-link"
:data-user-id=
"author.id"
:data-username=
"author.username"
>
<span
class=
"note-header-author-name"
>
{{
author
.
name
}}
</span>
<span
v-if=
"author.status_tooltip_html"
v-html=
"author.status_tooltip_html"
></span>
<span
class=
"note-headline-light"
>
@
{{
author
.
username
}}
</span>
...
...
app/assets/javascripts/notes/components/notes_app.vue
View file @
ddd4cc64
...
...
@@ -12,6 +12,7 @@ import placeholderNote from '../../vue_shared/components/notes/placeholder_note.
import
placeholderSystemNote
from
'
../../vue_shared/components/notes/placeholder_system_note.vue
'
;
import
skeletonLoadingContainer
from
'
../../vue_shared/components/notes/skeleton_note.vue
'
;
import
highlightCurrentUser
from
'
~/behaviors/markdown/highlight_current_user
'
;
import
initUserPopovers
from
'
../../user_popovers
'
;
export
default
{
name
:
'
NotesApp
'
,
...
...
@@ -106,7 +107,10 @@ export default {
}
},
updated
()
{
this
.
$nextTick
(()
=>
highlightCurrentUser
(
this
.
$el
.
querySelectorAll
(
'
.gfm-project_member
'
)));
this
.
$nextTick
(()
=>
{
highlightCurrentUser
(
this
.
$el
.
querySelectorAll
(
'
.gfm-project_member
'
));
initUserPopovers
(
this
.
$el
.
querySelectorAll
(
'
.js-user-link
'
));
});
},
methods
:
{
...
mapActions
([
...
...
app/assets/javascripts/user_popovers.js
0 → 100644
View file @
ddd4cc64
import
Vue
from
'
vue
'
;
import
UsersCache
from
'
./lib/utils/users_cache
'
;
import
UserPopover
from
'
./vue_shared/components/user_popover/user_popover.vue
'
;
let
renderedPopover
;
let
renderFn
;
const
handleUserPopoverMouseOut
=
event
=>
{
const
{
target
}
=
event
;
target
.
removeEventListener
(
'
mouseleave
'
,
handleUserPopoverMouseOut
);
if
(
renderFn
)
{
clearTimeout
(
renderFn
);
}
if
(
renderedPopover
)
{
renderedPopover
.
$destroy
();
renderedPopover
=
null
;
}
};
/**
* Adds a UserPopover component to the body, hands over as much data as the target element has in data attributes.
* loads based on data-user-id more data about a user from the API and sets it on the popover
*/
const
handleUserPopoverMouseOver
=
event
=>
{
const
{
target
}
=
event
;
// Add listener to actually remove it again
target
.
addEventListener
(
'
mouseleave
'
,
handleUserPopoverMouseOut
);
renderFn
=
setTimeout
(()
=>
{
// Helps us to use current markdown setup without maybe breaking or duplicating for now
if
(
target
.
dataset
.
user
)
{
target
.
dataset
.
userId
=
target
.
dataset
.
user
;
// Removing titles so its not showing tooltips also
target
.
dataset
.
originalTitle
=
''
;
target
.
setAttribute
(
'
title
'
,
''
);
}
const
{
userId
,
username
,
name
,
avatarUrl
}
=
target
.
dataset
;
const
user
=
{
userId
,
username
,
name
,
avatarUrl
,
location
:
null
,
bio
:
null
,
organization
:
null
,
status
:
null
,
loaded
:
false
,
};
if
(
userId
||
username
)
{
const
UserPopoverComponent
=
Vue
.
extend
(
UserPopover
);
renderedPopover
=
new
UserPopoverComponent
({
propsData
:
{
target
,
user
,
},
});
renderedPopover
.
$mount
();
UsersCache
.
retrieveById
(
userId
)
.
then
(
userData
=>
{
if
(
!
userData
)
{
return
;
}
Object
.
assign
(
user
,
{
avatarUrl
:
userData
.
avatar_url
,
username
:
userData
.
username
,
name
:
userData
.
name
,
location
:
userData
.
location
,
bio
:
userData
.
bio
,
organization
:
userData
.
organization
,
loaded
:
true
,
});
UsersCache
.
retrieveStatusById
(
userId
)
.
then
(
status
=>
{
if
(
!
status
)
{
return
;
}
Object
.
assign
(
user
,
{
status
,
});
})
.
catch
(()
=>
{
throw
new
Error
(
`User status for "
${
userId
}
" could not be retrieved!`
);
});
})
.
catch
(()
=>
{
renderedPopover
.
$destroy
();
renderedPopover
=
null
;
});
}
},
200
);
// 200ms delay so not every mouseover triggers Popover + API Call
};
export
default
elements
=>
{
const
userLinks
=
elements
||
[...
document
.
querySelectorAll
(
'
.js-user-link
'
)];
userLinks
.
forEach
(
el
=>
{
el
.
addEventListener
(
'
mouseenter
'
,
handleUserPopoverMouseOver
);
});
};
app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
View file @
ddd4cc64
...
...
@@ -67,7 +67,7 @@ export default {
// In both cases we should render the defaultAvatarUrl
sanitizedSource
()
{
let
baseSrc
=
this
.
imgSrc
===
''
||
this
.
imgSrc
===
null
?
defaultAvatarUrl
:
this
.
imgSrc
;
if
(
baseSrc
.
indexOf
(
'
?
'
)
===
-
1
)
baseSrc
+=
`?width=
${
this
.
size
}
`
;
if
(
!
baseSrc
.
startsWith
(
'
data:
'
)
&&
!
baseSrc
.
includes
(
'
?
'
)
)
baseSrc
+=
`?width=
${
this
.
size
}
`
;
return
baseSrc
;
},
resultantSrcAttribute
()
{
...
...
@@ -97,6 +97,7 @@ export default {
class="avatar"
/>
<gl-tooltip
v-if=
"tooltipText || $slots.default"
:target=
"() => $refs.userAvatarImage"
:placement=
"tooltipPlacement"
boundary=
"window"
...
...
app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
0 → 100644
View file @
ddd4cc64
<
script
>
import
{
GlPopover
,
GlSkeletonLoading
}
from
'
@gitlab/ui
'
;
import
{
__
,
sprintf
}
from
'
~/locale
'
;
import
UserAvatarImage
from
'
../user_avatar/user_avatar_image.vue
'
;
import
{
glEmojiTag
}
from
'
../../../emoji
'
;
export
default
{
name
:
'
UserPopover
'
,
components
:
{
GlPopover
,
GlSkeletonLoading
,
UserAvatarImage
,
},
props
:
{
target
:
{
type
:
HTMLAnchorElement
,
required
:
true
,
},
user
:
{
type
:
Object
,
required
:
true
,
default
:
null
,
},
loaded
:
{
type
:
Boolean
,
required
:
false
,
default
:
false
,
},
},
computed
:
{
jobLine
()
{
if
(
this
.
user
.
bio
&&
this
.
user
.
organization
)
{
return
sprintf
(
__
(
'
%{bio} at %{organization}
'
),
{
bio
:
this
.
user
.
bio
,
organization
:
this
.
user
.
organization
,
});
}
else
if
(
this
.
user
.
bio
)
{
return
this
.
user
.
bio
;
}
else
if
(
this
.
user
.
organization
)
{
return
this
.
user
.
organization
;
}
return
null
;
},
statusHtml
()
{
if
(
this
.
user
.
status
.
emoji
&&
this
.
user
.
status
.
message
)
{
return
`
${
glEmojiTag
(
this
.
user
.
status
.
emoji
)}
${
this
.
user
.
status
.
message
}
`
;
}
else
if
(
this
.
user
.
status
.
message
)
{
return
this
.
user
.
status
.
message
;
}
return
''
;
},
nameIsLoading
()
{
return
!
this
.
user
.
name
;
},
jobInfoIsLoading
()
{
return
!
this
.
user
.
loaded
&&
this
.
user
.
organization
===
null
;
},
locationIsLoading
()
{
return
!
this
.
user
.
loaded
&&
this
.
user
.
location
===
null
;
},
},
};
</
script
>
<
template
>
<gl-popover
:target=
"target"
boundary=
"viewport"
placement=
"top"
show
>
<div
class=
"user-popover d-flex"
>
<div
class=
"p-1 flex-shrink-1"
>
<user-avatar-image
:img-src=
"user.avatarUrl"
:size=
"60"
css-classes=
"mr-2"
/>
</div>
<div
class=
"p-1 w-100"
>
<h5
class=
"m-0"
>
{{
user
.
name
}}
<gl-skeleton-loading
v-if=
"nameIsLoading"
:lines=
"1"
class=
"animation-container-small mb-1"
/>
</h5>
<div
class=
"text-secondary mb-2"
>
<span
v-if=
"user.username"
>
@
{{
user
.
username
}}
</span>
<gl-skeleton-loading
v-else
:lines=
"1"
class=
"animation-container-small mb-1"
/>
</div>
<div
class=
"text-secondary"
>
{{
jobLine
}}
<gl-skeleton-loading
v-if=
"jobInfoIsLoading"
:lines=
"1"
class=
"animation-container-small mb-1"
/>
</div>
<div
class=
"text-secondary"
>
{{
user
.
location
}}
<gl-skeleton-loading
v-if=
"locationIsLoading"
:lines=
"1"
class=
"animation-container-small mb-1"
/>
</div>
<div
v-if=
"user.status"
class=
"mt-2"
><span
v-html=
"statusHtml"
></span></div>
</div>
</div>
</gl-popover>
</
template
>
app/assets/stylesheets/application.scss
View file @
ddd4cc64
...
...
@@ -34,6 +34,11 @@
*/
@import
"pages/**/*"
;
/*
* Component specific styles, will be moved to gitlab-ui
*/
@import
"components/**/*"
;
/*
* Code highlight
*/
...
...
app/assets/stylesheets/components/popover.scss
0 → 100644
View file @
ddd4cc64
.popover
{
min-width
:
300px
;
.popover-body
.user-popover
{
padding
:
$gl-padding-8
;
font-size
:
$gl-font-size-small
;
line-height
:
$gl-line-height
;
}
}
app/assets/stylesheets/framework/variables.scss
View file @
ddd4cc64
...
...
@@ -172,6 +172,7 @@ $theme-light-red-700: #a62e21;
$black
:
#000
;
$black-transparent
:
rgba
(
0
,
0
,
0
,
0
.3
);
$shadow-color
:
rgba
(
$black
,
0
.1
);
$almost-black
:
#242424
;
$border-white-light
:
darken
(
$white-light
,
$darken-border-factor
);
...
...
app/assets/stylesheets/framework/variables_overrides.scss
View file @
ddd4cc64
...
...
@@ -21,3 +21,8 @@ $danger: $red-500;
$zindex-modal-backdrop
:
1040
;
$nav-divider-margin-y
:
(
$grid-size
/
2
);
$dropdown-divider-bg
:
$theme-gray-200
;
$popover-max-width
:
300px
;
$popover-border-width
:
1px
;
$popover-border-color
:
$border-color
;
$popover-box-shadow
:
0
$border-radius-small
$border-radius-default
0
$shadow-color
;
$popover-arrow-outer-color
:
$shadow-color
;
app/helpers/issuables_helper.rb
View file @
ddd4cc64
...
...
@@ -179,7 +179,7 @@ module IssuablesHelper
output
<<
"Opened
#{
time_ago_with_tooltip
(
issuable
.
created_at
)
}
by "
.
html_safe
output
<<
content_tag
(
:strong
)
do
author_output
=
link_to_member
(
project
,
issuable
.
author
,
size:
24
,
mobile_classes:
"d-none d-sm-inline"
,
tooltip:
true
)
author_output
=
link_to_member
(
project
,
issuable
.
author
,
size:
24
,
mobile_classes:
"d-none d-sm-inline"
)
author_output
<<
link_to_member
(
project
,
issuable
.
author
,
size:
24
,
by_username:
true
,
avatar:
false
,
mobile_classes:
"d-block d-sm-none"
)
if
status
=
user_status
(
issuable
.
author
)
...
...
app/helpers/projects_helper.rb
View file @
ddd4cc64
...
...
@@ -50,6 +50,12 @@ module ProjectsHelper
default_opts
=
{
avatar:
true
,
name:
true
,
title:
":name"
}
opts
=
default_opts
.
merge
(
opts
)
data_attrs
=
{
user_id:
author
.
id
,
username:
author
.
username
,
name:
author
.
name
}
return
"(deleted)"
unless
author
author_html
=
[]
...
...
@@ -65,7 +71,7 @@ module ProjectsHelper
author_html
=
author_html
.
join
.
html_safe
if
opts
[
:name
]
link_to
(
author_html
,
user_path
(
author
),
class:
"author-link
#{
"
#{
opts
[
:extra_class
]
}
"
if
opts
[
:extra_class
]
}
#{
"
#{
opts
[
:mobile_classes
]
}
"
if
opts
[
:mobile_classes
]
}
"
).
html_safe
link_to
(
author_html
,
user_path
(
author
),
class:
"author-link
js-user-link
#{
"
#{
opts
[
:extra_class
]
}
"
if
opts
[
:extra_class
]
}
#{
"
#{
opts
[
:mobile_classes
]
}
"
if
opts
[
:mobile_classes
]
}
"
,
data:
data_attrs
).
html_safe
else
title
=
opts
[
:title
].
sub
(
":name"
,
sanitize
(
author
.
name
))
link_to
(
author_html
,
user_path
(
author
),
class:
"author-link has-tooltip"
,
title:
title
,
data:
{
container:
'body'
}).
html_safe
...
...
changelogs/unreleased/50157-extended-user-centric-tooltips.yml
0 → 100644
View file @
ddd4cc64
---
title
:
Extended user centric tooltips on issue and MR page
merge_request
:
23231
author
:
type
:
added
lib/banzai/filter/user_reference_filter.rb
View file @
ddd4cc64
...
...
@@ -106,7 +106,7 @@ module Banzai
end
def
link_class
reference_class
(
:project_member
)
reference_class
(
:project_member
,
tooltip:
false
)
end
def
link_to_all
(
link_content:
nil
)
...
...
locale/gitlab.pot
View file @
ddd4cc64
...
...
@@ -97,6 +97,9 @@ msgstr[1] ""
msgid "%{actionText} & %{openOrClose} %{noteable}"
msgstr ""
msgid "%{bio} at %{organization}"
msgstr ""
msgid "%{commit_author_link} authored %{commit_timeago}"
msgstr ""
...
...
spec/javascripts/api_spec.js
View file @
ddd4cc64
...
...
@@ -333,6 +333,40 @@ describe('Api', () => {
});
});
describe
(
'
user
'
,
()
=>
{
it
(
'
fetches single user
'
,
done
=>
{
const
userId
=
'
123456
'
;
const
expectedUrl
=
`
${
dummyUrlRoot
}
/api/
${
dummyApiVersion
}
/users/
${
userId
}
`
;
mock
.
onGet
(
expectedUrl
).
reply
(
200
,
{
name
:
'
testuser
'
,
});
Api
.
user
(
userId
)
.
then
(({
data
})
=>
{
expect
(
data
.
name
).
toBe
(
'
testuser
'
);
})
.
then
(
done
)
.
catch
(
done
.
fail
);
});
});
describe
(
'
user status
'
,
()
=>
{
it
(
'
fetches single user status
'
,
done
=>
{
const
userId
=
'
123456
'
;
const
expectedUrl
=
`
${
dummyUrlRoot
}
/api/
${
dummyApiVersion
}
/users/
${
userId
}
/status`
;
mock
.
onGet
(
expectedUrl
).
reply
(
200
,
{
message
:
'
testmessage
'
,
});
Api
.
userStatus
(
userId
)
.
then
(({
data
})
=>
{
expect
(
data
.
message
).
toBe
(
'
testmessage
'
);
})
.
then
(
done
)
.
catch
(
done
.
fail
);
});
});
describe
(
'
commitPipelines
'
,
()
=>
{
it
(
'
fetches pipelines for a given commit
'
,
done
=>
{
const
projectId
=
'
example/foobar
'
;
...
...
spec/javascripts/lib/utils/users_cache_spec.js
View file @
ddd4cc64
...
...
@@ -3,7 +3,9 @@ import UsersCache from '~/lib/utils/users_cache';
describe
(
'
UsersCache
'
,
()
=>
{
const
dummyUsername
=
'
win
'
;
const
dummyUser
=
'
has a farm
'
;
const
dummyUserId
=
123
;
const
dummyUser
=
{
name
:
'
has a farm
'
,
username
:
'
farmer
'
};
const
dummyUserStatus
=
'
my status
'
;
beforeEach
(()
=>
{
UsersCache
.
internalStorage
=
{};
...
...
@@ -135,4 +137,110 @@ describe('UsersCache', () => {
.
catch
(
done
.
fail
);
});
});
describe
(
'
retrieveById
'
,
()
=>
{
let
apiSpy
;
beforeEach
(()
=>
{
spyOn
(
Api
,
'
user
'
).
and
.
callFake
(
id
=>
apiSpy
(
id
));
});
it
(
'
stores and returns data from API call if cache is empty
'
,
done
=>
{
apiSpy
=
id
=>
{
expect
(
id
).
toBe
(
dummyUserId
);
return
Promise
.
resolve
({
data
:
dummyUser
,
});
};
UsersCache
.
retrieveById
(
dummyUserId
)
.
then
(
user
=>
{
expect
(
user
).
toBe
(
dummyUser
);
expect
(
UsersCache
.
internalStorage
[
dummyUserId
]).
toBe
(
dummyUser
);
})
.
then
(
done
)
.
catch
(
done
.
fail
);
});
it
(
'
returns undefined if Ajax call fails and cache is empty
'
,
done
=>
{
const
dummyError
=
new
Error
(
'
server exploded
'
);
apiSpy
=
id
=>
{
expect
(
id
).
toBe
(
dummyUserId
);
return
Promise
.
reject
(
dummyError
);
};
UsersCache
.
retrieveById
(
dummyUserId
)
.
then
(
user
=>
fail
(
`Received unexpected user:
${
JSON
.
stringify
(
user
)}
`
))
.
catch
(
error
=>
{
expect
(
error
).
toBe
(
dummyError
);
})
.
then
(
done
)
.
catch
(
done
.
fail
);
});
it
(
'
makes no Ajax call if matching data exists
'
,
done
=>
{
UsersCache
.
internalStorage
[
dummyUserId
]
=
dummyUser
;
apiSpy
=
()
=>
fail
(
new
Error
(
'
expected no Ajax call!
'
));
UsersCache
.
retrieveById
(
dummyUserId
)
.
then
(
user
=>
{
expect
(
user
).
toBe
(
dummyUser
);
})
.
then
(
done
)
.
catch
(
done
.
fail
);
});
});
describe
(
'
retrieveStatusById
'
,
()
=>
{
let
apiSpy
;
beforeEach
(()
=>
{
spyOn
(
Api
,
'
userStatus
'
).
and
.
callFake
(
id
=>
apiSpy
(
id
));
});
it
(
'
stores and returns data from API call if cache is empty
'
,
done
=>
{
apiSpy
=
id
=>
{
expect
(
id
).
toBe
(
dummyUserId
);
return
Promise
.
resolve
({
data
:
dummyUserStatus
,
});
};
UsersCache
.
retrieveStatusById
(
dummyUserId
)
.
then
(
userStatus
=>
{
expect
(
userStatus
).
toBe
(
dummyUserStatus
);
expect
(
UsersCache
.
internalStorage
[
dummyUserId
].
status
).
toBe
(
dummyUserStatus
);
})
.
then
(
done
)
.
catch
(
done
.
fail
);
});
it
(
'
returns undefined if Ajax call fails and cache is empty
'
,
done
=>
{
const
dummyError
=
new
Error
(
'
server exploded
'
);
apiSpy
=
id
=>
{
expect
(
id
).
toBe
(
dummyUserId
);
return
Promise
.
reject
(
dummyError
);
};
UsersCache
.
retrieveStatusById
(
dummyUserId
)
.
then
(
userStatus
=>
fail
(
`Received unexpected user:
${
JSON
.
stringify
(
userStatus
)}
`
))
.
catch
(
error
=>
{
expect
(
error
).
toBe
(
dummyError
);
})
.
then
(
done
)
.
catch
(
done
.
fail
);
});
it
(
'
makes no Ajax call if matching data exists
'
,
done
=>
{
UsersCache
.
internalStorage
[
dummyUserId
]
=
{
status
:
dummyUserStatus
};
apiSpy
=
()
=>
fail
(
new
Error
(
'
expected no Ajax call!
'
));
UsersCache
.
retrieveStatusById
(
dummyUserId
)
.
then
(
userStatus
=>
{
expect
(
userStatus
).
toBe
(
dummyUserStatus
);
})
.
then
(
done
)
.
catch
(
done
.
fail
);
});
});
});
spec/javascripts/notes/components/note_edited_text_spec.js
View file @
ddd4cc64
...
...
@@ -39,7 +39,7 @@ describe('note_edited_text', () => {
});
it
(
'
should render provided user information
'
,
()
=>
{
const
authorLink
=
vm
.
$el
.
querySelector
(
'
.js-
vue-author
'
);
const
authorLink
=
vm
.
$el
.
querySelector
(
'
.js-
user-link
'
);
expect
(
authorLink
.
getAttribute
(
'
href
'
)).
toEqual
(
props
.
editedBy
.
path
);
expect
(
authorLink
.
textContent
.
trim
()).
toEqual
(
props
.
editedBy
.
name
);
...
...
spec/javascripts/notes/components/note_header_spec.js
View file @
ddd4cc64
...
...
@@ -42,6 +42,9 @@ describe('note_header component', () => {
it
(
'
should render user information
'
,
()
=>
{
expect
(
vm
.
$el
.
querySelector
(
'
.note-header-author-name
'
).
textContent
.
trim
()).
toEqual
(
'
Root
'
);
expect
(
vm
.
$el
.
querySelector
(
'
.note-header-info a
'
).
getAttribute
(
'
href
'
)).
toEqual
(
'
/root
'
);
expect
(
vm
.
$el
.
querySelector
(
'
.note-header-info a
'
).
dataset
.
userId
).
toEqual
(
'
1
'
);
expect
(
vm
.
$el
.
querySelector
(
'
.note-header-info a
'
).
dataset
.
username
).
toEqual
(
'
root
'
);
expect
(
vm
.
$el
.
querySelector
(
'
.note-header-info a
'
).
classList
).
toContain
(
'
js-user-link
'
);
});
it
(
'
should render timestamp link
'
,
()
=>
{
...
...
spec/javascripts/user_popovers_spec.js
0 → 100644
View file @
ddd4cc64
import
initUserPopovers
from
'
~/user_popovers
'
;
import
UsersCache
from
'
~/lib/utils/users_cache
'
;
describe
(
'
User Popovers
'
,
()
=>
{
const
selector
=
'
.js-user-link
'
;
const
dummyUser
=
{
name
:
'
root
'
};
const
dummyUserStatus
=
{
message
:
'
active
'
};
const
triggerEvent
=
(
eventName
,
el
)
=>
{
const
event
=
document
.
createEvent
(
'
MouseEvents
'
);
event
.
initMouseEvent
(
eventName
,
true
,
true
,
window
);
el
.
dispatchEvent
(
event
);
};
beforeEach
(()
=>
{
setFixtures
(
`
<a href="/root" data-user-id="1" class="js-user-link" data-username="root" data-original-title="" title="">
Root
</a>
`
);
const
usersCacheSpy
=
()
=>
Promise
.
resolve
(
dummyUser
);
spyOn
(
UsersCache
,
'
retrieveById
'
).
and
.
callFake
(
userId
=>
usersCacheSpy
(
userId
));
const
userStatusCacheSpy
=
()
=>
Promise
.
resolve
(
dummyUserStatus
);
spyOn
(
UsersCache
,
'
retrieveStatusById
'
).
and
.
callFake
(
userId
=>
userStatusCacheSpy
(
userId
));
initUserPopovers
(
document
.
querySelectorAll
(
'
.js-user-link
'
));
});
it
(
'
Should Show+Hide Popover on mouseenter and mouseleave
'
,
done
=>
{
triggerEvent
(
'
mouseenter
'
,
document
.
querySelector
(
selector
));
setTimeout
(()
=>
{
const
shownPopover
=
document
.
querySelector
(
'
.popover
'
);
expect
(
shownPopover
).
not
.
toBeNull
();
expect
(
shownPopover
.
innerHTML
).
toContain
(
dummyUser
.
name
);
expect
(
UsersCache
.
retrieveById
).
toHaveBeenCalledWith
(
'
1
'
);
triggerEvent
(
'
mouseleave
'
,
document
.
querySelector
(
selector
));
setTimeout
(()
=>
{
// After Mouse leave it should be hidden now
expect
(
document
.
querySelector
(
'
.popover
'
)).
toBeNull
();
done
();
});
},
210
);
// We need to wait until the 200ms mouseover delay is over, only then the popover will be visible
});
it
(
'
Should Not show a popover on short mouse over
'
,
done
=>
{
triggerEvent
(
'
mouseenter
'
,
document
.
querySelector
(
selector
));
setTimeout
(()
=>
{
expect
(
document
.
querySelector
(
'
.popover
'
)).
toBeNull
();
expect
(
UsersCache
.
retrieveById
).
not
.
toHaveBeenCalledWith
(
'
1
'
);
triggerEvent
(
'
mouseleave
'
,
document
.
querySelector
(
selector
));
done
();
});
});
});
spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js
View file @
ddd4cc64
...
...
@@ -2,6 +2,7 @@ import Vue from 'vue';
import
{
placeholderImage
}
from
'
~/lazy_loader
'
;
import
userAvatarImage
from
'
~/vue_shared/components/user_avatar/user_avatar_image.vue
'
;
import
mountComponent
,
{
mountComponentWithSlots
}
from
'
spec/helpers/vue_mount_component_helper
'
;
import
defaultAvatarUrl
from
'
~/../images/no_avatar.png
'
;
const
DEFAULT_PROPS
=
{
size
:
99
,
...
...
@@ -76,6 +77,18 @@ describe('User Avatar Image Component', function() {
});
});
describe
(
'
Initialization without src
'
,
function
()
{
beforeEach
(
function
()
{
vm
=
mountComponent
(
UserAvatarImage
);
});
it
(
'
should have default avatar image
'
,
function
()
{
const
imageElement
=
vm
.
$el
.
querySelector
(
'
img
'
);
expect
(
imageElement
.
getAttribute
(
'
src
'
)).
toBe
(
defaultAvatarUrl
);
});
});
describe
(
'
dynamic tooltip content
'
,
()
=>
{
const
props
=
DEFAULT_PROPS
;
const
slots
=
{
...
...
spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js
View file @
ddd4cc64
...
...
@@ -74,9 +74,7 @@ describe('User Avatar Link Component', function() {
describe
(
'
username
'
,
function
()
{
it
(
'
should not render avatar image tooltip
'
,
function
()
{
expect
(
this
.
userAvatarLink
.
$el
.
querySelector
(
'
.js-user-avatar-image-toolip
'
).
innerText
.
trim
(),
).
toEqual
(
''
);
expect
(
this
.
userAvatarLink
.
$el
.
querySelector
(
'
.js-user-avatar-image-toolip
'
)).
toBeNull
();
});
it
(
'
should render username prop in <span>
'
,
function
()
{
...
...
spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js
0 → 100644
View file @
ddd4cc64
import
Vue
from
'
vue
'
;
import
userPopover
from
'
~/vue_shared/components/user_popover/user_popover.vue
'
;
import
mountComponent
from
'
spec/helpers/vue_mount_component_helper
'
;
const
DEFAULT_PROPS
=
{
loaded
:
true
,
user
:
{
username
:
'
root
'
,
name
:
'
Administrator
'
,
location
:
'
Vienna
'
,
bio
:
null
,
organization
:
null
,
status
:
null
,
},
};
const
UserPopover
=
Vue
.
extend
(
userPopover
);
describe
(
'
User Popover Component
'
,
()
=>
{
let
vm
;
beforeEach
(()
=>
{
setFixtures
(
`
<a href="/root" data-user-id="1" class="js-user-link" title="testuser">
Root
</a>
`
);
});
afterEach
(()
=>
{
vm
.
$destroy
();
});
describe
(
'
Empty
'
,
()
=>
{
beforeEach
(()
=>
{
vm
=
mountComponent
(
UserPopover
,
{
target
:
document
.
querySelector
(
'
.js-user-link
'
),
user
:
{
name
:
null
,
username
:
null
,
location
:
null
,
bio
:
null
,
organization
:
null
,
status
:
null
,
},
});
});
it
(
'
should return skeleton loaders
'
,
()
=>
{
expect
(
vm
.
$el
.
querySelectorAll
(
'
.animation-container
'
).
length
).
toBe
(
4
);
});
});
describe
(
'
basic data
'
,
()
=>
{
it
(
'
should show basic fields
'
,
()
=>
{
vm
=
mountComponent
(
UserPopover
,
{
...
DEFAULT_PROPS
,
target
:
document
.
querySelector
(
'
.js-user-link
'
),
});
expect
(
vm
.
$el
.
textContent
).
toContain
(
DEFAULT_PROPS
.
user
.
name
);
expect
(
vm
.
$el
.
textContent
).
toContain
(
DEFAULT_PROPS
.
user
.
username
);
expect
(
vm
.
$el
.
textContent
).
toContain
(
DEFAULT_PROPS
.
user
.
location
);
});
});
describe
(
'
job data
'
,
()
=>
{
it
(
'
should show only bio if no organization is available
'
,
()
=>
{
const
testProps
=
Object
.
assign
({},
DEFAULT_PROPS
);
testProps
.
user
.
bio
=
'
Engineer
'
;
vm
=
mountComponent
(
UserPopover
,
{
...
testProps
,
target
:
document
.
querySelector
(
'
.js-user-link
'
),
});
expect
(
vm
.
$el
.
textContent
).
toContain
(
'
Engineer
'
);
});
it
(
'
should show only organization if no bio is available
'
,
()
=>
{
const
testProps
=
Object
.
assign
({},
DEFAULT_PROPS
);
testProps
.
user
.
organization
=
'
GitLab
'
;
vm
=
mountComponent
(
UserPopover
,
{
...
testProps
,
target
:
document
.
querySelector
(
'
.js-user-link
'
),
});
expect
(
vm
.
$el
.
textContent
).
toContain
(
'
GitLab
'
);
});
it
(
'
should have full job line when we have bio and organization
'
,
()
=>
{
const
testProps
=
Object
.
assign
({},
DEFAULT_PROPS
);
testProps
.
user
.
bio
=
'
Engineer
'
;
testProps
.
user
.
organization
=
'
GitLab
'
;
vm
=
mountComponent
(
UserPopover
,
{
...
DEFAULT_PROPS
,
target
:
document
.
querySelector
(
'
.js-user-link
'
),
});
expect
(
vm
.
$el
.
textContent
).
toContain
(
'
Engineer at GitLab
'
);
});
});
describe
(
'
status data
'
,
()
=>
{
it
(
'
should show only message
'
,
()
=>
{
const
testProps
=
Object
.
assign
({},
DEFAULT_PROPS
);
testProps
.
user
.
status
=
{
message
:
'
Hello World
'
};
vm
=
mountComponent
(
UserPopover
,
{
...
DEFAULT_PROPS
,
target
:
document
.
querySelector
(
'
.js-user-link
'
),
});
expect
(
vm
.
$el
.
textContent
).
toContain
(
'
Hello World
'
);
});
it
(
'
should show message and emoji
'
,
()
=>
{
const
testProps
=
Object
.
assign
({},
DEFAULT_PROPS
);
testProps
.
user
.
status
=
{
emoji
:
'
basketball_player
'
,
message
:
'
Hello World
'
};
vm
=
mountComponent
(
UserPopover
,
{
...
DEFAULT_PROPS
,
target
:
document
.
querySelector
(
'
.js-user-link
'
),
status
:
{
emoji
:
'
basketball_player
'
,
message
:
'
Hello World
'
},
});
expect
(
vm
.
$el
.
textContent
).
toContain
(
'
Hello World
'
);
expect
(
vm
.
$el
.
innerHTML
).
toContain
(
'
<gl-emoji data-name="basketball_player"
'
);
});
});
});
spec/lib/banzai/filter/user_reference_filter_spec.rb
View file @
ddd4cc64
...
...
@@ -120,7 +120,7 @@ describe Banzai::Filter::UserReferenceFilter do
it
'includes default classes'
do
doc
=
reference_filter
(
"Hey
#{
reference
}
"
)
expect
(
doc
.
css
(
'a'
).
first
.
attr
(
'class'
)).
to
eq
'gfm gfm-project_member
has-tooltip
'
expect
(
doc
.
css
(
'a'
).
first
.
attr
(
'class'
)).
to
eq
'gfm gfm-project_member'
end
context
'when a project is not specified'
do
...
...
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