Commit ddd4cc64 authored by Tim Zallmann's avatar Tim Zallmann Committed by Clement Ho

Resolve "Extended user centric tooltips"

parent a89b5269
...@@ -21,7 +21,9 @@ const Api = { ...@@ -21,7 +21,9 @@ const Api = {
projectTemplatePath: '/api/:version/projects/:id/templates/:type/:key', projectTemplatePath: '/api/:version/projects/:id/templates/:type/:key',
projectTemplatesPath: '/api/:version/projects/:id/templates/:type', projectTemplatesPath: '/api/:version/projects/:id/templates/:type',
usersPath: '/api/:version/users.json', 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', commitPath: '/api/:version/projects/:id/repository/commits',
commitPipelinesPath: '/:project_id/commit/:sha/pipelines', commitPipelinesPath: '/:project_id/commit/:sha/pipelines',
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch', branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
...@@ -254,6 +256,20 @@ const Api = { ...@@ -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 = {}) { branches(id, query = '', options = {}) {
const url = Api.buildUrl(this.createBranchPath).replace(':id', encodeURIComponent(id)); const url = Api.buildUrl(this.createBranchPath).replace(':id', encodeURIComponent(id));
...@@ -276,7 +292,7 @@ const Api = { ...@@ -276,7 +292,7 @@ const Api = {
}, },
postUserStatus({ emoji, message }) { postUserStatus({ emoji, message }) {
const url = Api.buildUrl(this.userStatusPath); const url = Api.buildUrl(this.userPostStatusPath);
return axios.put(url, { return axios.put(url, {
emoji, emoji,
......
...@@ -3,6 +3,7 @@ import syntaxHighlight from '~/syntax_highlight'; ...@@ -3,6 +3,7 @@ import syntaxHighlight from '~/syntax_highlight';
import renderMath from './render_math'; import renderMath from './render_math';
import renderMermaid from './render_mermaid'; import renderMermaid from './render_mermaid';
import highlightCurrentUser from './highlight_current_user'; import highlightCurrentUser from './highlight_current_user';
import initUserPopovers from '../../user_popovers';
// Render GitLab flavoured Markdown // Render GitLab flavoured Markdown
// //
...@@ -13,6 +14,7 @@ $.fn.renderGFM = function renderGFM() { ...@@ -13,6 +14,7 @@ $.fn.renderGFM = function renderGFM() {
renderMath(this.find('.js-render-math')); renderMath(this.find('.js-render-math'));
renderMermaid(this.find('.js-render-mermaid')); renderMermaid(this.find('.js-render-mermaid'));
highlightCurrentUser(this.find('.gfm-project_member').get()); highlightCurrentUser(this.find('.gfm-project_member').get());
initUserPopovers(this.find('.gfm-project_member').get());
return this; return this;
}; };
......
...@@ -22,6 +22,34 @@ class UsersCache extends Cache { ...@@ -22,6 +22,34 @@ class UsersCache extends Cache {
}); });
// missing catch is intentional, error handling depends on use case // 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(); export default new UsersCache();
...@@ -30,6 +30,7 @@ import initUsagePingConsent from './usage_ping_consent'; ...@@ -30,6 +30,7 @@ import initUsagePingConsent from './usage_ping_consent';
import initPerformanceBar from './performance_bar'; import initPerformanceBar from './performance_bar';
import initSearchAutocomplete from './search_autocomplete'; import initSearchAutocomplete from './search_autocomplete';
import GlFieldErrors from './gl_field_errors'; import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers';
// expose jQuery as global (TODO: remove these) // expose jQuery as global (TODO: remove these)
window.jQuery = jQuery; window.jQuery = jQuery;
...@@ -78,6 +79,7 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -78,6 +79,7 @@ document.addEventListener('DOMContentLoaded', () => {
initTodoToggle(); initTodoToggle();
initLogoAnimation(); initLogoAnimation();
initUsagePingConsent(); initUsagePingConsent();
initUserPopovers();
if (document.querySelector('.search')) initSearchAutocomplete(); if (document.querySelector('.search')) initSearchAutocomplete();
if (document.querySelector('#js-peek')) initPerformanceBar({ container: '#js-peek' }); if (document.querySelector('#js-peek')) initPerformanceBar({ container: '#js-peek' });
......
...@@ -39,7 +39,10 @@ export default { ...@@ -39,7 +39,10 @@ export default {
<div :class="className"> <div :class="className">
{{ actionText }} {{ actionText }}
<template v-if="editedBy"> <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> </template>
{{ actionDetailText }} {{ actionDetailText }}
<time-ago-tooltip :time="editedAt" tooltip-placement="bottom" /> <time-ago-tooltip :time="editedAt" tooltip-placement="bottom" />
......
...@@ -73,7 +73,14 @@ export default { ...@@ -73,7 +73,14 @@ export default {
{{ __('Toggle discussion') }} {{ __('Toggle discussion') }}
</button> </button>
</div> </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 class="note-header-author-name">{{ author.name }}</span>
<span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span> <span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span>
<span class="note-headline-light"> @{{ author.username }} </span> <span class="note-headline-light"> @{{ author.username }} </span>
......
...@@ -12,6 +12,7 @@ import placeholderNote from '../../vue_shared/components/notes/placeholder_note. ...@@ -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 placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue'; import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue';
import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user';
import initUserPopovers from '../../user_popovers';
export default { export default {
name: 'NotesApp', name: 'NotesApp',
...@@ -106,7 +107,10 @@ export default { ...@@ -106,7 +107,10 @@ export default {
} }
}, },
updated() { 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: { methods: {
...mapActions([ ...mapActions([
......
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);
});
};
...@@ -67,7 +67,7 @@ export default { ...@@ -67,7 +67,7 @@ export default {
// In both cases we should render the defaultAvatarUrl // In both cases we should render the defaultAvatarUrl
sanitizedSource() { sanitizedSource() {
let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; 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; return baseSrc;
}, },
resultantSrcAttribute() { resultantSrcAttribute() {
...@@ -97,6 +97,7 @@ export default { ...@@ -97,6 +97,7 @@ export default {
class="avatar" class="avatar"
/> />
<gl-tooltip <gl-tooltip
v-if="tooltipText || $slots.default"
:target="() => $refs.userAvatarImage" :target="() => $refs.userAvatarImage"
:placement="tooltipPlacement" :placement="tooltipPlacement"
boundary="window" boundary="window"
......
<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>
...@@ -34,6 +34,11 @@ ...@@ -34,6 +34,11 @@
*/ */
@import "pages/**/*"; @import "pages/**/*";
/*
* Component specific styles, will be moved to gitlab-ui
*/
@import "components/**/*";
/* /*
* Code highlight * Code highlight
*/ */
......
.popover {
min-width: 300px;
.popover-body .user-popover {
padding: $gl-padding-8;
font-size: $gl-font-size-small;
line-height: $gl-line-height;
}
}
...@@ -172,6 +172,7 @@ $theme-light-red-700: #a62e21; ...@@ -172,6 +172,7 @@ $theme-light-red-700: #a62e21;
$black: #000; $black: #000;
$black-transparent: rgba(0, 0, 0, 0.3); $black-transparent: rgba(0, 0, 0, 0.3);
$shadow-color: rgba($black, 0.1);
$almost-black: #242424; $almost-black: #242424;
$border-white-light: darken($white-light, $darken-border-factor); $border-white-light: darken($white-light, $darken-border-factor);
......
...@@ -21,3 +21,8 @@ $danger: $red-500; ...@@ -21,3 +21,8 @@ $danger: $red-500;
$zindex-modal-backdrop: 1040; $zindex-modal-backdrop: 1040;
$nav-divider-margin-y: ($grid-size / 2); $nav-divider-margin-y: ($grid-size / 2);
$dropdown-divider-bg: $theme-gray-200; $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;
...@@ -179,7 +179,7 @@ module IssuablesHelper ...@@ -179,7 +179,7 @@ module IssuablesHelper
output << "Opened #{time_ago_with_tooltip(issuable.created_at)} by ".html_safe output << "Opened #{time_ago_with_tooltip(issuable.created_at)} by ".html_safe
output << content_tag(:strong) do 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") 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) if status = user_status(issuable.author)
......
...@@ -50,6 +50,12 @@ module ProjectsHelper ...@@ -50,6 +50,12 @@ module ProjectsHelper
default_opts = { avatar: true, name: true, title: ":name" } default_opts = { avatar: true, name: true, title: ":name" }
opts = default_opts.merge(opts) opts = default_opts.merge(opts)
data_attrs = {
user_id: author.id,
username: author.username,
name: author.name
}
return "(deleted)" unless author return "(deleted)" unless author
author_html = [] author_html = []
...@@ -65,7 +71,7 @@ module ProjectsHelper ...@@ -65,7 +71,7 @@ module ProjectsHelper
author_html = author_html.join.html_safe author_html = author_html.join.html_safe
if opts[:name] 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 else
title = opts[:title].sub(":name", sanitize(author.name)) 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 link_to(author_html, user_path(author), class: "author-link has-tooltip", title: title, data: { container: 'body' }).html_safe
......
---
title: Extended user centric tooltips on issue and MR page
merge_request: 23231
author:
type: added
...@@ -106,7 +106,7 @@ module Banzai ...@@ -106,7 +106,7 @@ module Banzai
end end
def link_class def link_class
reference_class(:project_member) reference_class(:project_member, tooltip: false)
end end
def link_to_all(link_content: nil) def link_to_all(link_content: nil)
......
...@@ -97,6 +97,9 @@ msgstr[1] "" ...@@ -97,6 +97,9 @@ msgstr[1] ""
msgid "%{actionText} & %{openOrClose} %{noteable}" msgid "%{actionText} & %{openOrClose} %{noteable}"
msgstr "" msgstr ""
msgid "%{bio} at %{organization}"
msgstr ""
msgid "%{commit_author_link} authored %{commit_timeago}" msgid "%{commit_author_link} authored %{commit_timeago}"
msgstr "" msgstr ""
......
...@@ -333,6 +333,40 @@ describe('Api', () => { ...@@ -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', () => { describe('commitPipelines', () => {
it('fetches pipelines for a given commit', done => { it('fetches pipelines for a given commit', done => {
const projectId = 'example/foobar'; const projectId = 'example/foobar';
......
...@@ -3,7 +3,9 @@ import UsersCache from '~/lib/utils/users_cache'; ...@@ -3,7 +3,9 @@ import UsersCache from '~/lib/utils/users_cache';
describe('UsersCache', () => { describe('UsersCache', () => {
const dummyUsername = 'win'; const dummyUsername = 'win';
const dummyUser = 'has a farm'; const dummyUserId = 123;
const dummyUser = { name: 'has a farm', username: 'farmer' };
const dummyUserStatus = 'my status';
beforeEach(() => { beforeEach(() => {
UsersCache.internalStorage = {}; UsersCache.internalStorage = {};
...@@ -135,4 +137,110 @@ describe('UsersCache', () => { ...@@ -135,4 +137,110 @@ describe('UsersCache', () => {
.catch(done.fail); .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);
});
});
}); });
...@@ -39,7 +39,7 @@ describe('note_edited_text', () => { ...@@ -39,7 +39,7 @@ describe('note_edited_text', () => {
}); });
it('should render provided user information', () => { 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.getAttribute('href')).toEqual(props.editedBy.path);
expect(authorLink.textContent.trim()).toEqual(props.editedBy.name); expect(authorLink.textContent.trim()).toEqual(props.editedBy.name);
......
...@@ -42,6 +42,9 @@ describe('note_header component', () => { ...@@ -42,6 +42,9 @@ describe('note_header component', () => {
it('should render user information', () => { it('should render user information', () => {
expect(vm.$el.querySelector('.note-header-author-name').textContent.trim()).toEqual('Root'); 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').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', () => { it('should render timestamp link', () => {
......
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();
});
});
});
...@@ -2,6 +2,7 @@ import Vue from 'vue'; ...@@ -2,6 +2,7 @@ import Vue from 'vue';
import { placeholderImage } from '~/lazy_loader'; import { placeholderImage } from '~/lazy_loader';
import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import mountComponent, { mountComponentWithSlots } from 'spec/helpers/vue_mount_component_helper'; import mountComponent, { mountComponentWithSlots } from 'spec/helpers/vue_mount_component_helper';
import defaultAvatarUrl from '~/../images/no_avatar.png';
const DEFAULT_PROPS = { const DEFAULT_PROPS = {
size: 99, size: 99,
...@@ -76,6 +77,18 @@ describe('User Avatar Image Component', function() { ...@@ -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', () => { describe('dynamic tooltip content', () => {
const props = DEFAULT_PROPS; const props = DEFAULT_PROPS;
const slots = { const slots = {
......
...@@ -74,9 +74,7 @@ describe('User Avatar Link Component', function() { ...@@ -74,9 +74,7 @@ describe('User Avatar Link Component', function() {
describe('username', function() { describe('username', function() {
it('should not render avatar image tooltip', function() { it('should not render avatar image tooltip', function() {
expect( expect(this.userAvatarLink.$el.querySelector('.js-user-avatar-image-toolip')).toBeNull();
this.userAvatarLink.$el.querySelector('.js-user-avatar-image-toolip').innerText.trim(),
).toEqual('');
}); });
it('should render username prop in <span>', function() { it('should render username prop in <span>', function() {
......
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"');
});
});
});
...@@ -120,7 +120,7 @@ describe Banzai::Filter::UserReferenceFilter do ...@@ -120,7 +120,7 @@ describe Banzai::Filter::UserReferenceFilter do
it 'includes default classes' do it 'includes default classes' do
doc = reference_filter("Hey #{reference}") 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 end
context 'when a project is not specified' do context 'when a project is not specified' do
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment