Commit 691b83eb authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '199211-fix-user-popover-glitch' into 'master'

Fix user popover glitch

Closes #199211

See merge request gitlab-org/gitlab!23904
parents 3688aa18 114e24b4
...@@ -14,7 +14,7 @@ import placeholderSystemNote from '../../vue_shared/components/notes/placeholder ...@@ -14,7 +14,7 @@ import placeholderSystemNote from '../../vue_shared/components/notes/placeholder
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 { __ } from '~/locale'; import { __ } from '~/locale';
import initUserPopovers from '../../user_popovers'; import initUserPopovers from '~/user_popovers';
export default { export default {
name: 'NotesApp', name: 'NotesApp',
......
...@@ -3,108 +3,92 @@ import Vue from 'vue'; ...@@ -3,108 +3,92 @@ import Vue from 'vue';
import UsersCache from './lib/utils/users_cache'; import UsersCache from './lib/utils/users_cache';
import UserPopover from './vue_shared/components/user_popover/user_popover.vue'; import UserPopover from './vue_shared/components/user_popover/user_popover.vue';
let renderedPopover; const removeTitle = el => {
let renderFn; // Removing titles so its not showing tooltips also
const handleUserPopoverMouseOut = event => { el.dataset.originalTitle = '';
const { target } = event; el.setAttribute('title', '');
target.removeEventListener('mouseleave', handleUserPopoverMouseOut); };
if (renderFn) { const getPreloadedUserInfo = dataset => {
clearTimeout(renderFn); const userId = dataset.user || dataset.userId;
} const { username, name, avatarUrl } = dataset;
if (renderedPopover) {
renderedPopover.$destroy(); return {
renderedPopover = null; userId,
} username,
target.removeAttribute('aria-describedby'); name,
avatarUrl,
};
}; };
/** /**
* Adds a UserPopover component to the body, hands over as much data as the target element has in data attributes. * 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 * loads based on data-user-id more data about a user from the API and sets it on the popover
*/ */
const handleUserPopoverMouseOver = event => { const populateUserInfo = user => {
const { target } = event; const { userId } = user;
// Add listener to actually remove it again
target.addEventListener('mouseleave', handleUserPopoverMouseOut); return Promise.all([UsersCache.retrieveById(userId), UsersCache.retrieveStatusById(userId)]).then(
([userData, status]) => {
renderFn = setTimeout(() => { if (userData) {
// Helps us to use current markdown setup without maybe breaking or duplicating for now Object.assign(user, {
if (target.dataset.user) { avatarUrl: userData.avatar_url,
target.dataset.userId = target.dataset.user; username: userData.username,
// Removing titles so its not showing tooltips also name: userData.name,
target.dataset.originalTitle = ''; location: userData.location,
target.setAttribute('title', ''); bio: userData.bio,
} organization: userData.organization,
loaded: true,
const { userId, username, name, avatarUrl } = target.dataset; });
}
if (status) {
Object.assign(user, {
status,
});
}
return user;
},
);
};
export default (elements = document.querySelectorAll('.js-user-link')) => {
const userLinks = Array.from(elements);
return userLinks.map(el => {
const UserPopoverComponent = Vue.extend(UserPopover);
const user = { const user = {
userId,
username,
name,
avatarUrl,
location: null, location: null,
bio: null, bio: null,
organization: null, organization: null,
status: null, status: null,
loaded: false, loaded: false,
}; };
if (userId || username) { const renderedPopover = new UserPopoverComponent({
const UserPopoverComponent = Vue.extend(UserPopover); propsData: {
renderedPopover = new UserPopoverComponent({ target: el,
propsData: { user,
target, },
user, });
},
}); renderedPopover.$mount();
renderedPopover.$mount(); el.addEventListener('mouseenter', ({ target }) => {
removeTitle(target);
UsersCache.retrieveById(userId) const preloadedUserInfo = getPreloadedUserInfo(target.dataset);
.then(userData => {
if (!userData) { Object.assign(user, preloadedUserInfo);
return undefined;
}
Object.assign(user, {
avatarUrl: userData.avatar_url,
username: userData.username,
name: userData.name,
location: userData.location,
bio: userData.bio,
organization: userData.organization,
status: userData.status,
loaded: true,
});
if (userData.status) {
return Promise.resolve();
}
return UsersCache.retrieveStatusById(userId);
})
.then(status => {
if (!status) {
return;
}
Object.assign(user, {
status,
});
})
.catch(() => {
renderedPopover.$destroy();
renderedPopover = null;
});
}
}, 200); // 200ms delay so not every mouseover triggers Popover + API Call
};
export default elements => { if (preloadedUserInfo.userId) {
const userLinks = elements || [...document.querySelectorAll('.js-user-link')]; populateUserInfo(user);
}
});
el.addEventListener('mouseleave', ({ target }) => {
target.removeAttribute('aria-describedby');
});
userLinks.forEach(el => { return renderedPopover;
el.addEventListener('mouseenter', handleUserPopoverMouseOver);
}); });
}; };
...@@ -56,19 +56,16 @@ export default { ...@@ -56,19 +56,16 @@ export default {
</script> </script>
<template> <template>
<gl-popover :target="target" boundary="viewport" placement="top" offset="0, 1" show> <!-- 200ms delay so not every mouseover triggers Popover -->
<gl-popover :target="target" :delay="200" boundary="viewport" triggers="hover" placement="top">
<div class="user-popover d-flex"> <div class="user-popover d-flex">
<div class="p-1 flex-shrink-1"> <div class="p-1 flex-shrink-1">
<user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="mr-2" /> <user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="mr-2" />
</div> </div>
<div class="p-1 w-100"> <div class="p-1 w-100">
<h5 class="m-0"> <h5 class="m-0">
{{ user.name }} <span v-if="user.name">{{ user.name }}</span>
<gl-skeleton-loading <gl-skeleton-loading v-else :lines="1" class="animation-container-small mb-1" />
v-if="nameIsLoading"
:lines="1"
class="animation-container-small mb-1"
/>
</h5> </h5>
<div class="text-secondary mb-2"> <div class="text-secondary mb-2">
<span v-if="user.username">@{{ user.username }}</span> <span v-if="user.username">@{{ user.username }}</span>
......
---
title: Fix user popover glitch
merge_request: 23904
author:
type: fixed
# frozen_string_literal: true
require 'spec_helper'
describe 'User sees user popover', :js do
set(:project) { create(:project, :repository) }
let(:user) { project.creator }
let(:merge_request) do
create(:merge_request, source_project: project, target_project: project)
end
before do
project.add_maintainer(user)
sign_in(user)
end
subject { page }
describe 'hovering over a user link in a merge request' do
before do
visit project_merge_request_path(project, merge_request)
end
it 'displays user popover' do
popover_selector = '.user-popover'
find('.js-user-link').hover
expect(page).to have_css(popover_selector, visible: true)
page.within(popover_selector) do
expect(page).to have_content(user.name)
end
end
end
end
...@@ -12,6 +12,8 @@ import '~/behaviors/markdown/render_gfm'; ...@@ -12,6 +12,8 @@ import '~/behaviors/markdown/render_gfm';
import * as mockData from '../../notes/mock_data'; import * as mockData from '../../notes/mock_data';
import * as urlUtility from '~/lib/utils/url_utility'; import * as urlUtility from '~/lib/utils/url_utility';
jest.mock('~/user_popovers', () => jest.fn());
setTestTimeout(1000); setTestTimeout(1000);
describe('note_app', () => { describe('note_app', () => {
......
...@@ -10,9 +10,14 @@ describe('User Popovers', () => { ...@@ -10,9 +10,14 @@ describe('User Popovers', () => {
const dummyUser = { name: 'root' }; const dummyUser = { name: 'root' };
const dummyUserStatus = { message: 'active' }; const dummyUserStatus = { message: 'active' };
let popovers;
const triggerEvent = (eventName, el) => { const triggerEvent = (eventName, el) => {
const event = document.createEvent('MouseEvents'); const event = new MouseEvent(eventName, {
event.initMouseEvent(eventName, true, true, window); bubbles: true,
cancelable: true,
view: window,
});
el.dispatchEvent(event); el.dispatchEvent(event);
}; };
...@@ -26,46 +31,54 @@ describe('User Popovers', () => { ...@@ -26,46 +31,54 @@ describe('User Popovers', () => {
const userStatusCacheSpy = () => Promise.resolve(dummyUserStatus); const userStatusCacheSpy = () => Promise.resolve(dummyUserStatus);
spyOn(UsersCache, 'retrieveStatusById').and.callFake(userId => userStatusCacheSpy(userId)); spyOn(UsersCache, 'retrieveStatusById').and.callFake(userId => userStatusCacheSpy(userId));
initUserPopovers(document.querySelectorAll('.js-user-link')); popovers = initUserPopovers(document.querySelectorAll(selector));
}); });
it('Should Show+Hide Popover on mouseenter and mouseleave', done => { it('initializes a popover for each js-user-link element found in the document', () => {
const targetLink = document.querySelector(selector); expect(document.querySelectorAll(selector).length).toBe(popovers.length);
const { userId } = targetLink.dataset; });
triggerEvent('mouseenter', targetLink);
setTimeout(() => { describe('when user link emits mouseenter event', () => {
const shownPopover = document.querySelector('.popover'); let userLink;
expect(shownPopover).not.toBeNull(); beforeEach(() => {
expect(targetLink.getAttribute('aria-describedby')).not.toBeNull(); userLink = document.querySelector(selector);
expect(shownPopover.innerHTML).toContain(dummyUser.name); triggerEvent('mouseenter', userLink);
expect(UsersCache.retrieveById).toHaveBeenCalledWith(userId.toString()); });
triggerEvent('mouseleave', targetLink); it('removes title attribute from user links', () => {
expect(userLink.getAttribute('title')).toBeFalsy();
expect(userLink.dataset.originalTitle).toBeFalsy();
});
setTimeout(() => { it('populates popovers with preloaded user data', () => {
// After Mouse leave it should be hidden now const { name, userId, username } = userLink.dataset;
expect(document.querySelector('.popover')).toBeNull(); const [firstPopover] = popovers;
expect(targetLink.getAttribute('aria-describedby')).toBeNull();
done(); expect(firstPopover.$props.user).toEqual(
}); jasmine.objectContaining({
}, 210); // We need to wait until the 200ms mouseover delay is over, only then the popover will be visible name,
}); userId,
username,
}),
);
});
it('Should Not show a popover on short mouse over', done => { it('fetches user info and status from the user cache', () => {
const targetLink = document.querySelector(selector); const { userId } = userLink.dataset;
const { userId } = targetLink.dataset;
triggerEvent('mouseenter', targetLink);
setTimeout(() => { expect(UsersCache.retrieveById).toHaveBeenCalledWith(userId);
expect(document.querySelector('.popover')).toBeNull(); expect(UsersCache.retrieveStatusById).toHaveBeenCalledWith(userId);
expect(UsersCache.retrieveById).not.toHaveBeenCalledWith(userId.toString()); });
});
triggerEvent('mouseleave', targetLink); it('removes aria-describedby attribute from the user link on mouseleave', () => {
const userLink = document.querySelector(selector);
done(); userLink.setAttribute('aria-describedby', 'popover');
}); triggerEvent('mouseleave', userLink);
expect(userLink.getAttribute('aria-describedby')).toBe(null);
}); });
}); });
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