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 syntaxHighlight from '~/syntax_highlight';
import initUserPopovers from '../../user_popovers';
import highlightCurrentUser from './highlight_current_user';
import { renderKroki } from './render_kroki';
import renderMath from './render_math';
......@@ -21,6 +22,7 @@ $.fn.renderGFM = function renderGFM() {
renderMermaid(this.find('.js-render-mermaid'));
}
highlightCurrentUser(this.find('.gfm-project_member').get());
initUserPopovers(this.find('.js-user-link').get());
const mrPopoverElements = this.find('.gfm-merge_request').get();
if (mrPopoverElements.length) {
......
......@@ -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 glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import initUserPopovers from '../../user_popovers';
/**
* CommitItem
*
......@@ -80,6 +82,11 @@ export default {
return this.commit.description_html.replace(/^
/, '');
},
},
created() {
this.$nextTick(() => {
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
});
},
safeHtmlConfig: {
ADD_TAGS: ['gl-emoji'],
},
......
......@@ -4,6 +4,7 @@ import { mapState } from 'vuex';
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 { mergeUrlParams } from '~/lib/utils/url_utility';
import initUserPopovers from '~/user_popovers';
import UserDate from '~/vue_shared/components/user_date.vue';
import {
FIELD_KEY_ACTIONS,
......@@ -84,6 +85,9 @@ export default {
return this.tabQueryParamValue === TAB_QUERY_PARAM_VALUES.invite;
},
},
mounted() {
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
},
methods: {
hasActionButtons(member) {
return (
......
......@@ -3,6 +3,7 @@ import { mapGetters, mapActions } from 'vuex';
import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user';
import createFlash from '~/flash';
import { __ } from '~/locale';
import initUserPopovers from '~/user_popovers';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import OrderedLayout from '~/vue_shared/components/ordered_layout.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
......@@ -168,6 +169,7 @@ export default {
updated() {
this.$nextTick(() => {
highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member'));
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
});
},
beforeDestroy() {
......
......@@ -57,35 +57,41 @@ const populateUserInfo = (user) => {
);
};
function initPopover(el, user, mountPopover) {
const preloadedUserInfo = getPreloadedUserInfo(el.dataset);
const initializedPopovers = new Map();
let domObservedForChanges = false;
Object.assign(user, preloadedUserInfo);
const addPopoversToModifiedTree = new MutationObserver(() => {
const userLinks = document?.querySelectorAll('.js-user-link, .gfm-project_member');
if (preloadedUserInfo.userId) {
populateUserInfo(user);
if (userLinks) {
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) {
userLinks
.filter(({ dataset, user }) => !user && (dataset.user || dataset.userId))
.forEach((el) => {
export default function addPopovers(elements = document.querySelectorAll('.js-user-link')) {
const userLinks = Array.from(elements);
const UserPopoverComponent = Vue.extend(UserPopover);
observeBody();
return userLinks
.filter(({ dataset }) => dataset.user || dataset.userId)
.map((el) => {
if (initializedPopovers.has(el)) {
return initializedPopovers.get(el);
}
const user = {
location: null,
bio: null,
......@@ -93,60 +99,31 @@ function initPopovers(userLinks, mountPopover) {
status: null,
loaded: false,
};
el.user = user;
const init = initPopover.bind(null, el, user, mountPopover);
el.addEventListener('mouseenter', init, { once: true });
el.addEventListener('mouseenter', ({ target, isSelfTriggered }) => {
if (!isSelfTriggered) return;
removeTitle(target);
const renderedPopover = new UserPopoverComponent({
propsData: {
target: el,
user,
},
});
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) => {
if (!('matches' in node)) return null;
if (node.matches(userLinkSelector)) return [node];
return Array.from(node.querySelectorAll(userLinkSelector));
};
renderedPopover.$mount();
let observer;
el.addEventListener('mouseenter', ({ target }) => {
removeTitle(target);
const preloadedUserInfo = getPreloadedUserInfo(target.dataset);
export default function addPopovers(
elements = document.querySelectorAll('.js-user-link'),
mountPopover = (popoverInstance) => popoverInstance.$mount(),
) {
const userLinks = Array.from(elements);
Object.assign(user, preloadedUserInfo);
initPopovers(userLinks, mountPopover);
if (!observer) {
observer = new MutationObserver((mutationsList) => {
const newUserLinks = mutationsList
.filter((mutation) => mutation.type === 'childList' && mutation.addedNodes)
.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,
});
if (preloadedUserInfo.userId) {
populateUserInfo(user);
}
});
el.addEventListener('mouseleave', ({ target }) => {
target.removeAttribute('aria-describedby');
});
document.addEventListener('beforeunload', () => {
observer.disconnect();
return renderedPopover;
});
}
}
......@@ -18,6 +18,7 @@ import { toggleContainerClasses } from '~/lib/utils/dom_utils';
import { visitUrl, joinPaths } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
import initUserPopovers from '~/user_popovers';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { PAGE_CONFIG, SEVERITY_LEVELS } from '../constants';
......@@ -174,6 +175,7 @@ export default {
updated() {
this.$nextTick(() => {
highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member'));
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
});
},
methods: {
......
......@@ -10,6 +10,7 @@ import createFlash from '~/flash';
import { TYPE_VULNERABILITY } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { s__ } from '~/locale';
import initUserPopovers from '~/user_popovers';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { normalizeGraphQLNote } from '../helpers';
import GenericReportSection from './generic_report/report_section.vue';
......@@ -116,6 +117,11 @@ export default {
this.stopPolling();
this.unbindVisibilityListener();
},
updated() {
this.$nextTick(() => {
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
});
},
methods: {
startPolling() {
if (this.pollInterval) {
......
......@@ -18,9 +18,11 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import initUserPopovers from '~/user_popovers';
import { addTypenamesToDiscussion, generateNote } from './mock_data';
jest.mock('~/flash');
jest.mock('~/user_popovers');
Vue.use(VueApollo);
......@@ -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 () => {
await createWrapperAndFetchDiscussions({
errors: [{ message: 'Something went wrong' }],
......
......@@ -6,6 +6,8 @@ import Component from '~/diffs/components/commit_item.vue';
import { getTimeago } from '~/lib/utils/datetime_utility';
import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
jest.mock('~/user_popovers');
const TEST_AUTHOR_NAME = 'test';
const TEST_AUTHOR_EMAIL = 'test+test@gitlab.com';
const TEST_AUTHOR_GRAVATAR = `${TEST_HOST}/avatar/test?s=40`;
......
......@@ -21,6 +21,7 @@ import {
BADGE_LABELS_PENDING_OWNER_APPROVAL,
TAB_QUERY_PARAM_VALUES,
} from '~/members/constants';
import * as initUserPopovers from '~/user_popovers';
import {
member as memberMock,
directMember,
......@@ -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', () => {
createComponent();
......
......@@ -18,6 +18,8 @@ import '~/behaviors/markdown/render_gfm';
import OrderedLayout from '~/vue_shared/components/ordered_layout.vue';
import * as mockData from '../mock_data';
jest.mock('~/user_popovers', () => jest.fn());
setTestTimeout(1000);
const TYPE_COMMENT_FORM = 'comment-form';
......
......@@ -18,13 +18,12 @@ describe('User Popovers', () => {
return link;
};
const findPopovers = () => {
return Array.from(document.querySelectorAll('[data-testid="user-popover"]'));
};
const dummyUser = { name: 'root' };
const dummyUserStatus = { message: 'active' };
let popovers;
const triggerEvent = (eventName, el) => {
const event = new MouseEvent(eventName, {
bubbles: true,
......@@ -46,49 +45,29 @@ describe('User Popovers', () => {
.spyOn(UsersCache, 'retrieveStatusById')
.mockImplementation((userId) => userStatusCacheSpy(userId));
initUserPopovers(document.querySelectorAll(selector), (popoverInstance) => {
const mountingRoot = document.createElement('div');
document.body.appendChild(mountingRoot);
popoverInstance.$mount(mountingRoot);
});
popovers = initUserPopovers(document.querySelectorAll(selector));
});
describe('shows a placeholder popover on hover', () => {
let linksWithUsers;
beforeEach(() => {
linksWithUsers = findFixtureLinks();
linksWithUsers.forEach((el) => {
triggerEvent('mouseenter', el);
});
});
it('initializes a popover for each user link with a user id', () => {
const linksWithUsers = findFixtureLinks();
it('for initial links', () => {
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);
});
expect(linksWithUsers.length).toBe(popovers.length);
});
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) => {
triggerEvent('mouseenter', link);
});
const linksWithUsers = findFixtureLinks();
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', () => {
const [firstUserLink] = findFixtureLinks();
triggerEvent('mouseenter', firstUserLink);
triggerEvent('mouseleave', firstUserLink);
triggerEvent('mouseenter', firstUserLink);
const newPopovers = initUserPopovers(document.querySelectorAll(selector));
const samePopovers = popovers.every((popover, index) => newPopovers[index] === popover);
expect(findPopovers().length).toBe(1);
expect(samePopovers).toBe(true);
});
describe('when user link emits mouseenter event', () => {
......@@ -107,11 +86,11 @@ describe('User Popovers', () => {
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 [firstPopover] = findFixtureLinks();
const [firstPopover] = popovers;
expect(firstPopover.user).toEqual(
expect(firstPopover.$props.user).toEqual(
expect.objectContaining({
name,
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