Commit ec92bba6 authored by Paul Slaughter's avatar Paul Slaughter

Merge branch '358958-fix-sticky-user-popovers' into 'master'

Fix "sticky" user popovers

See merge request gitlab-org/gitlab!85120
parents f659545e 9d772ad5
import $ from 'jquery'; import $ from 'jquery';
import syntaxHighlight from '~/syntax_highlight'; import syntaxHighlight from '~/syntax_highlight';
import initUserPopovers from '../../user_popovers';
import highlightCurrentUser from './highlight_current_user'; import highlightCurrentUser from './highlight_current_user';
import { renderKroki } from './render_kroki'; import { renderKroki } from './render_kroki';
import renderMath from './render_math'; import renderMath from './render_math';
...@@ -21,6 +22,7 @@ $.fn.renderGFM = function renderGFM() { ...@@ -21,6 +22,7 @@ $.fn.renderGFM = function renderGFM() {
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('.js-user-link').get());
const mrPopoverElements = this.find('.gfm-merge_request').get(); const mrPopoverElements = this.find('.gfm-merge_request').get();
if (mrPopoverElements.length) { if (mrPopoverElements.length) {
......
...@@ -7,6 +7,8 @@ import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; ...@@ -7,6 +7,8 @@ import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import initUserPopovers from '../../user_popovers';
/** /**
* CommitItem * CommitItem
* *
...@@ -80,6 +82,11 @@ export default { ...@@ -80,6 +82,11 @@ export default {
return this.commit.description_html.replace(/^
/, ''); return this.commit.description_html.replace(/^
/, '');
}, },
}, },
created() {
this.$nextTick(() => {
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
});
},
safeHtmlConfig: { safeHtmlConfig: {
ADD_TAGS: ['gl-emoji'], ADD_TAGS: ['gl-emoji'],
}, },
......
...@@ -4,6 +4,7 @@ import { mapState } from 'vuex'; ...@@ -4,6 +4,7 @@ import { mapState } from 'vuex';
import MembersTableCell from 'ee_else_ce/members/components/table/members_table_cell.vue'; import MembersTableCell from 'ee_else_ce/members/components/table/members_table_cell.vue';
import { canOverride, canRemove, canResend, canUpdate } from 'ee_else_ce/members/utils'; import { canOverride, canRemove, canResend, canUpdate } from 'ee_else_ce/members/utils';
import { mergeUrlParams } from '~/lib/utils/url_utility'; import { mergeUrlParams } from '~/lib/utils/url_utility';
import initUserPopovers from '~/user_popovers';
import UserDate from '~/vue_shared/components/user_date.vue'; import UserDate from '~/vue_shared/components/user_date.vue';
import { import {
FIELD_KEY_ACTIONS, FIELD_KEY_ACTIONS,
...@@ -84,6 +85,9 @@ export default { ...@@ -84,6 +85,9 @@ export default {
return this.tabQueryParamValue === TAB_QUERY_PARAM_VALUES.invite; return this.tabQueryParamValue === TAB_QUERY_PARAM_VALUES.invite;
}, },
}, },
mounted() {
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
},
methods: { methods: {
hasActionButtons(member) { hasActionButtons(member) {
return ( return (
......
...@@ -3,6 +3,7 @@ import { mapGetters, mapActions } from 'vuex'; ...@@ -3,6 +3,7 @@ import { mapGetters, mapActions } from 'vuex';
import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import initUserPopovers from '~/user_popovers';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import OrderedLayout from '~/vue_shared/components/ordered_layout.vue'; import OrderedLayout from '~/vue_shared/components/ordered_layout.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
...@@ -168,6 +169,7 @@ export default { ...@@ -168,6 +169,7 @@ export default {
updated() { updated() {
this.$nextTick(() => { this.$nextTick(() => {
highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member')); highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member'));
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
}); });
}, },
beforeDestroy() { beforeDestroy() {
......
...@@ -57,35 +57,41 @@ const populateUserInfo = (user) => { ...@@ -57,35 +57,41 @@ const populateUserInfo = (user) => {
); );
}; };
function initPopover(el, user, mountPopover) { const initializedPopovers = new Map();
const preloadedUserInfo = getPreloadedUserInfo(el.dataset); let domObservedForChanges = false;
Object.assign(user, preloadedUserInfo); const addPopoversToModifiedTree = new MutationObserver(() => {
const userLinks = document?.querySelectorAll('.js-user-link, .gfm-project_member');
if (preloadedUserInfo.userId) { if (userLinks) {
populateUserInfo(user); addPopovers(userLinks); /* eslint-disable-line no-use-before-define */
}
});
function observeBody() {
if (!domObservedForChanges) {
addPopoversToModifiedTree.observe(document.body, {
subtree: true,
childList: true,
});
domObservedForChanges = true;
} }
const UserPopoverComponent = Vue.extend(UserPopover);
const popoverInstance = new UserPopoverComponent({
propsData: {
target: el,
user,
},
});
mountPopover(popoverInstance);
// wait for component to actually mount
setTimeout(() => {
// trigger an event to force tooltip to show
const event = new MouseEvent('mouseenter');
event.isSelfTriggered = true;
el.dispatchEvent(event);
});
} }
function initPopovers(userLinks, mountPopover) { export default function addPopovers(elements = document.querySelectorAll('.js-user-link')) {
userLinks const userLinks = Array.from(elements);
.filter(({ dataset, user }) => !user && (dataset.user || dataset.userId)) const UserPopoverComponent = Vue.extend(UserPopover);
.forEach((el) => {
observeBody();
return userLinks
.filter(({ dataset }) => dataset.user || dataset.userId)
.map((el) => {
if (initializedPopovers.has(el)) {
return initializedPopovers.get(el);
}
const user = { const user = {
location: null, location: null,
bio: null, bio: null,
...@@ -93,60 +99,31 @@ function initPopovers(userLinks, mountPopover) { ...@@ -93,60 +99,31 @@ function initPopovers(userLinks, mountPopover) {
status: null, status: null,
loaded: false, loaded: false,
}; };
el.user = user; const renderedPopover = new UserPopoverComponent({
const init = initPopover.bind(null, el, user, mountPopover); propsData: {
el.addEventListener('mouseenter', init, { once: true }); target: el,
el.addEventListener('mouseenter', ({ target, isSelfTriggered }) => { user,
if (!isSelfTriggered) return; },
removeTitle(target);
}); });
el.addEventListener('mouseleave', ({ target }) => {
target.removeAttribute('aria-describedby');
});
});
}
const userLinkSelector = 'a.js-user-link, a.gfm-project_member'; initializedPopovers.set(el, renderedPopover);
const getUserLinkNodes = (node) => { renderedPopover.$mount();
if (!('matches' in node)) return null;
if (node.matches(userLinkSelector)) return [node];
return Array.from(node.querySelectorAll(userLinkSelector));
};
let observer; el.addEventListener('mouseenter', ({ target }) => {
removeTitle(target);
const preloadedUserInfo = getPreloadedUserInfo(target.dataset);
export default function addPopovers( Object.assign(user, preloadedUserInfo);
elements = document.querySelectorAll('.js-user-link'),
mountPopover = (popoverInstance) => popoverInstance.$mount(),
) {
const userLinks = Array.from(elements);
initPopovers(userLinks, mountPopover); if (preloadedUserInfo.userId) {
populateUserInfo(user);
if (!observer) { }
observer = new MutationObserver((mutationsList) => { });
const newUserLinks = mutationsList el.addEventListener('mouseleave', ({ target }) => {
.filter((mutation) => mutation.type === 'childList' && mutation.addedNodes) target.removeAttribute('aria-describedby');
.reduce((acc, mutation) => { });
const userLinkNodes = Array.from(mutation.addedNodes)
.flatMap(getUserLinkNodes)
.filter(Boolean);
acc.push(...userLinkNodes);
return acc;
}, []);
if (newUserLinks.length !== 0) {
initPopovers(newUserLinks, mountPopover);
}
});
observer.observe(document.body, {
subtree: true,
childList: true,
});
document.addEventListener('beforeunload', () => { return renderedPopover;
observer.disconnect();
}); });
}
} }
...@@ -18,6 +18,7 @@ import { toggleContainerClasses } from '~/lib/utils/dom_utils'; ...@@ -18,6 +18,7 @@ import { toggleContainerClasses } from '~/lib/utils/dom_utils';
import { visitUrl, joinPaths } from '~/lib/utils/url_utility'; import { visitUrl, joinPaths } from '~/lib/utils/url_utility';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import initUserPopovers from '~/user_popovers';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { PAGE_CONFIG, SEVERITY_LEVELS } from '../constants'; import { PAGE_CONFIG, SEVERITY_LEVELS } from '../constants';
...@@ -174,6 +175,7 @@ export default { ...@@ -174,6 +175,7 @@ export default {
updated() { updated() {
this.$nextTick(() => { this.$nextTick(() => {
highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member')); highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member'));
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
}); });
}, },
methods: { methods: {
......
...@@ -10,6 +10,7 @@ import createFlash from '~/flash'; ...@@ -10,6 +10,7 @@ import createFlash from '~/flash';
import { TYPE_VULNERABILITY } from '~/graphql_shared/constants'; import { TYPE_VULNERABILITY } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils'; import { convertToGraphQLId } from '~/graphql_shared/utils';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import initUserPopovers from '~/user_popovers';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { normalizeGraphQLNote } from '../helpers'; import { normalizeGraphQLNote } from '../helpers';
import GenericReportSection from './generic_report/report_section.vue'; import GenericReportSection from './generic_report/report_section.vue';
...@@ -116,6 +117,11 @@ export default { ...@@ -116,6 +117,11 @@ export default {
this.stopPolling(); this.stopPolling();
this.unbindVisibilityListener(); this.unbindVisibilityListener();
}, },
updated() {
this.$nextTick(() => {
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
});
},
methods: { methods: {
startPolling() { startPolling() {
if (this.pollInterval) { if (this.pollInterval) {
......
...@@ -18,9 +18,11 @@ import createMockApollo from 'helpers/mock_apollo_helper'; ...@@ -18,9 +18,11 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash'; import createFlash from '~/flash';
import initUserPopovers from '~/user_popovers';
import { addTypenamesToDiscussion, generateNote } from './mock_data'; import { addTypenamesToDiscussion, generateNote } from './mock_data';
jest.mock('~/flash'); jest.mock('~/flash');
jest.mock('~/user_popovers');
Vue.use(VueApollo); Vue.use(VueApollo);
...@@ -136,6 +138,13 @@ describe('Vulnerability Footer', () => { ...@@ -136,6 +138,13 @@ describe('Vulnerability Footer', () => {
}); });
}); });
it('calls initUserPopovers when the component is updated', async () => {
createWrapper({ queryHandler: discussionsHandler({ discussions: [] }) });
expect(initUserPopovers).not.toHaveBeenCalled();
await waitForPromises();
expect(initUserPopovers).toHaveBeenCalled();
});
it('shows an error the discussions could not be retrieved', async () => { it('shows an error the discussions could not be retrieved', async () => {
await createWrapperAndFetchDiscussions({ await createWrapperAndFetchDiscussions({
errors: [{ message: 'Something went wrong' }], errors: [{ message: 'Something went wrong' }],
......
...@@ -6,6 +6,8 @@ import Component from '~/diffs/components/commit_item.vue'; ...@@ -6,6 +6,8 @@ import Component from '~/diffs/components/commit_item.vue';
import { getTimeago } from '~/lib/utils/datetime_utility'; import { getTimeago } from '~/lib/utils/datetime_utility';
import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
jest.mock('~/user_popovers');
const TEST_AUTHOR_NAME = 'test'; const TEST_AUTHOR_NAME = 'test';
const TEST_AUTHOR_EMAIL = 'test+test@gitlab.com'; const TEST_AUTHOR_EMAIL = 'test+test@gitlab.com';
const TEST_AUTHOR_GRAVATAR = `${TEST_HOST}/avatar/test?s=40`; const TEST_AUTHOR_GRAVATAR = `${TEST_HOST}/avatar/test?s=40`;
......
...@@ -21,6 +21,7 @@ import { ...@@ -21,6 +21,7 @@ import {
BADGE_LABELS_PENDING_OWNER_APPROVAL, BADGE_LABELS_PENDING_OWNER_APPROVAL,
TAB_QUERY_PARAM_VALUES, TAB_QUERY_PARAM_VALUES,
} from '~/members/constants'; } from '~/members/constants';
import * as initUserPopovers from '~/user_popovers';
import { import {
member as memberMock, member as memberMock,
directMember, directMember,
...@@ -256,6 +257,14 @@ describe('MembersTable', () => { ...@@ -256,6 +257,14 @@ describe('MembersTable', () => {
}); });
}); });
it('initializes user popovers when mounted', () => {
const initUserPopoversMock = jest.spyOn(initUserPopovers, 'default');
createComponent();
expect(initUserPopoversMock).toHaveBeenCalled();
});
it('adds QA selector to table', () => { it('adds QA selector to table', () => {
createComponent(); createComponent();
......
...@@ -18,6 +18,8 @@ import '~/behaviors/markdown/render_gfm'; ...@@ -18,6 +18,8 @@ import '~/behaviors/markdown/render_gfm';
import OrderedLayout from '~/vue_shared/components/ordered_layout.vue'; import OrderedLayout from '~/vue_shared/components/ordered_layout.vue';
import * as mockData from '../mock_data'; import * as mockData from '../mock_data';
jest.mock('~/user_popovers', () => jest.fn());
setTestTimeout(1000); setTestTimeout(1000);
const TYPE_COMMENT_FORM = 'comment-form'; const TYPE_COMMENT_FORM = 'comment-form';
......
...@@ -18,13 +18,12 @@ describe('User Popovers', () => { ...@@ -18,13 +18,12 @@ describe('User Popovers', () => {
return link; return link;
}; };
const findPopovers = () => {
return Array.from(document.querySelectorAll('[data-testid="user-popover"]'));
};
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 = new MouseEvent(eventName, { const event = new MouseEvent(eventName, {
bubbles: true, bubbles: true,
...@@ -46,49 +45,29 @@ describe('User Popovers', () => { ...@@ -46,49 +45,29 @@ describe('User Popovers', () => {
.spyOn(UsersCache, 'retrieveStatusById') .spyOn(UsersCache, 'retrieveStatusById')
.mockImplementation((userId) => userStatusCacheSpy(userId)); .mockImplementation((userId) => userStatusCacheSpy(userId));
initUserPopovers(document.querySelectorAll(selector), (popoverInstance) => { popovers = initUserPopovers(document.querySelectorAll(selector));
const mountingRoot = document.createElement('div');
document.body.appendChild(mountingRoot);
popoverInstance.$mount(mountingRoot);
});
}); });
describe('shows a placeholder popover on hover', () => { it('initializes a popover for each user link with a user id', () => {
let linksWithUsers; const linksWithUsers = findFixtureLinks();
beforeEach(() => {
linksWithUsers = findFixtureLinks();
linksWithUsers.forEach((el) => {
triggerEvent('mouseenter', el);
});
});
it('for initial links', () => { expect(linksWithUsers.length).toBe(popovers.length);
expect(findPopovers().length).toBe(linksWithUsers.length); });
});
it('for elements added after initial load', async () => {
const addedLinks = [createUserLink(), createUserLink()];
addedLinks.forEach((link) => {
document.body.appendChild(link);
});
await Promise.resolve(); it('adds popovers to user links added to the DOM tree after the initial call', async () => {
document.body.appendChild(createUserLink());
document.body.appendChild(createUserLink());
addedLinks.forEach((link) => { const linksWithUsers = findFixtureLinks();
triggerEvent('mouseenter', link);
});
expect(findPopovers().length).toBe(linksWithUsers.length + addedLinks.length); expect(linksWithUsers.length).toBe(popovers.length + 2);
});
}); });
it('does not initialize the user popovers twice for the same element', () => { it('does not initialize the user popovers twice for the same element', () => {
const [firstUserLink] = findFixtureLinks(); const newPopovers = initUserPopovers(document.querySelectorAll(selector));
triggerEvent('mouseenter', firstUserLink); const samePopovers = popovers.every((popover, index) => newPopovers[index] === popover);
triggerEvent('mouseleave', firstUserLink);
triggerEvent('mouseenter', firstUserLink);
expect(findPopovers().length).toBe(1); expect(samePopovers).toBe(true);
}); });
describe('when user link emits mouseenter event', () => { describe('when user link emits mouseenter event', () => {
...@@ -107,11 +86,11 @@ describe('User Popovers', () => { ...@@ -107,11 +86,11 @@ describe('User Popovers', () => {
expect(userLink.dataset.originalTitle).toBeFalsy(); expect(userLink.dataset.originalTitle).toBeFalsy();
}); });
it('populates popover with preloaded user data', () => { it('populates popovers with preloaded user data', () => {
const { name, userId, username } = userLink.dataset; const { name, userId, username } = userLink.dataset;
const [firstPopover] = findFixtureLinks(); const [firstPopover] = popovers;
expect(firstPopover.user).toEqual( expect(firstPopover.$props.user).toEqual(
expect.objectContaining({ expect.objectContaining({
name, name,
userId, userId,
......
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