Commit d8eff7a6 authored by Enrique Alcántara's avatar Enrique Alcántara

Merge branch '224450-gl-popovers' into 'master'

Replace BS global popovers init with GlPopover

See merge request gitlab-org/gitlab!45084
parents ca27d956 83c56925
...@@ -40,6 +40,7 @@ import { initUserTracking, initDefaultTrackers } from './tracking'; ...@@ -40,6 +40,7 @@ import { initUserTracking, initDefaultTrackers } from './tracking';
import { __ } from './locale'; import { __ } from './locale';
import * as tooltips from '~/tooltips'; import * as tooltips from '~/tooltips';
import * as popovers from '~/popovers';
import 'ee_else_ce/main_ee'; import 'ee_else_ce/main_ee';
...@@ -81,7 +82,7 @@ document.addEventListener('beforeunload', () => { ...@@ -81,7 +82,7 @@ document.addEventListener('beforeunload', () => {
// Close any open tooltips // Close any open tooltips
tooltips.dispose(document.querySelectorAll('.has-tooltip, [data-toggle="tooltip"]')); tooltips.dispose(document.querySelectorAll('.has-tooltip, [data-toggle="tooltip"]'));
// Close any open popover // Close any open popover
$('[data-toggle="popover"]').popover('dispose'); popovers.dispose();
}); });
window.addEventListener('hashchange', handleLocationHash); window.addEventListener('hashchange', handleLocationHash);
...@@ -166,13 +167,7 @@ function deferredInitialisation() { ...@@ -166,13 +167,7 @@ function deferredInitialisation() {
}); });
// Initialize popovers // Initialize popovers
$body.popover({ popovers.initPopovers();
selector: '[data-toggle="popover"]',
trigger: 'focus',
// set the viewport to the main content, excluding the navigation bar, so
// the navigation can't overlap the popover
viewport: '.layout-page',
});
// Adding a helper class to activate animations only after all is rendered // Adding a helper class to activate animations only after all is rendered
setTimeout(() => $body.addClass('page-initialised'), 1000); setTimeout(() => $body.addClass('page-initialised'), 1000);
......
<script>
// We can't use v-safe-html here as the popover's title or content might contains SVGs that would
// be stripped by the directive's sanitizer. Instead, we fallback on v-html and we use GitLab's
// dompurify config that lets SVGs be rendered properly.
// Context: https://gitlab.com/gitlab-org/gitlab/-/issues/247207
/* eslint-disable vue/no-v-html */
import { GlPopover } from '@gitlab/ui';
import { sanitize } from '~/lib/dompurify';
const newPopover = element => {
const { content, html, placement, title, triggers = 'focus' } = element.dataset;
return {
target: element,
content,
html,
placement,
title,
triggers,
};
};
export default {
components: {
GlPopover,
},
data() {
return {
popovers: [],
};
},
created() {
this.observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
mutation.removedNodes.forEach(this.dispose);
});
});
},
beforeDestroy() {
this.observer.disconnect();
},
methods: {
addPopovers(elements) {
const newPopovers = elements.reduce((acc, element) => {
if (this.popoverExists(element)) {
return acc;
}
const popover = newPopover(element);
this.observe(popover);
return [...acc, popover];
}, []);
this.popovers.push(...newPopovers);
},
observe(popover) {
this.observer.observe(popover.target.parentElement, {
childList: true,
});
},
dispose(target) {
if (!target) {
this.popovers = [];
} else {
const index = this.popovers.findIndex(popover => popover.target === target);
if (index > -1) {
this.popovers.splice(index, 1);
}
}
},
popoverExists(element) {
return this.popovers.some(popover => popover.target === element);
},
getSafeHtml(html) {
return sanitize(html);
},
},
};
</script>
<template>
<div>
<gl-popover v-for="(popover, index) in popovers" :key="index" v-bind="popover">
<template #title>
<span v-if="popover.html" v-html="getSafeHtml(popover.title)"></span>
<span v-else>{{ popover.title }}</span>
</template>
<span v-if="popover.html" v-html="getSafeHtml(popover.content)"></span>
<span v-else>{{ popover.content }}</span>
</gl-popover>
</div>
</template>
import Vue from 'vue';
import { toArray } from 'lodash';
import PopoversComponent from './components/popovers.vue';
let app;
const APP_ELEMENT_ID = 'gl-popovers-app';
const getPopoversApp = () => {
if (!app) {
const container = document.createElement('div');
container.setAttribute('id', APP_ELEMENT_ID);
document.body.appendChild(container);
const Popovers = Vue.extend(PopoversComponent);
app = new Popovers();
app.$mount(`#${APP_ELEMENT_ID}`);
}
return app;
};
const isPopover = (node, selector) => node.matches && node.matches(selector);
const handlePopoverEvent = (rootTarget, e, selector) => {
for (let { target } = e; target && target !== rootTarget; target = target.parentNode) {
if (isPopover(target, selector)) {
getPopoversApp().addPopovers([target]);
break;
}
}
};
export const initPopovers = () => {
['mouseenter', 'focus', 'click'].forEach(event => {
document.addEventListener(
event,
e => handlePopoverEvent(document, e, '[data-toggle="popover"]'),
true,
);
});
return getPopoversApp();
};
export const dispose = elements => toArray(elements).forEach(getPopoversApp().dispose);
export const destroy = () => {
getPopoversApp().$destroy();
app = null;
};
...@@ -42,7 +42,7 @@ ...@@ -42,7 +42,7 @@
toggle: "popover", toggle: "popover",
placement: "top", placement: "top",
html: "true", html: "true",
trigger: "focus", triggers: "focus",
title: "<div class='gl-font-weight-normal gl-line-height-normal'>#{popover_title_text}</div>", title: "<div class='gl-font-weight-normal gl-line-height-normal'>#{popover_title_text}</div>",
content: "<a href='#{popover_content_url}' target='_blank' rel='noopener noreferrer nofollow'>#{popover_content_text}</a>", content: "<a href='#{popover_content_url}' target='_blank' rel='noopener noreferrer nofollow'>#{popover_content_text}</a>",
} } } }
......
...@@ -31,8 +31,8 @@ describe('Remediated badge component', () => { ...@@ -31,8 +31,8 @@ describe('Remediated badge component', () => {
}); });
it('links the badge and the popover', () => { it('links the badge and the popover', () => {
const { popover } = wrapper.vm.$refs; const popover = wrapper.find({ ref: 'popover' });
expect(popover.$attrs.target()).toEqual(findIcon().element.parentNode); expect(popover.props('target')()).toEqual(findIcon().element.parentNode);
}); });
it('displays the issues', () => { it('displays the issues', () => {
......
...@@ -27,8 +27,8 @@ describe('Remediated badge component', () => { ...@@ -27,8 +27,8 @@ describe('Remediated badge component', () => {
}); });
it('should link the badge and the popover', () => { it('should link the badge and the popover', () => {
const { popover } = wrapper.vm.$refs; const popover = wrapper.find({ ref: 'popover' });
expect(popover.$attrs.target()).toEqual(findBadge().element); expect(popover.props('target')()).toEqual(findBadge().element);
}); });
it('should pass down the data to the popover', () => { it('should pass down the data to the popover', () => {
......
...@@ -38,8 +38,16 @@ jest.mock('@gitlab/ui/dist/components/base/popover/popover.js', () => ({ ...@@ -38,8 +38,16 @@ jest.mock('@gitlab/ui/dist/components/base/popover/popover.js', () => ({
required: false, required: false,
default: () => [], default: () => [],
}, },
...Object.fromEntries(['target', 'triggers', 'placement'].map(prop => [prop, {}])),
}, },
render(h) { render(h) {
return h('div', this.$attrs, Object.keys(this.$slots).map(s => this.$slots[s])); return h(
'div',
{
class: 'gl-popover',
...this.$attrs,
},
Object.keys(this.$slots).map(s => this.$slots[s]),
);
}, },
})); }));
import { shallowMount } from '@vue/test-utils';
import { GlPopover } from '@gitlab/ui';
import { useMockMutationObserver } from 'helpers/mock_dom_observer';
import Popovers from '~/popovers/components/popovers.vue';
describe('popovers/components/popovers.vue', () => {
const { trigger: triggerMutate, observersCount } = useMockMutationObserver();
let wrapper;
const buildWrapper = (...targets) => {
wrapper = shallowMount(Popovers);
wrapper.vm.addPopovers(targets);
return wrapper.vm.$nextTick();
};
const createPopoverTarget = (options = {}) => {
const target = document.createElement('button');
const dataset = {
title: 'default title',
content: 'some content',
...options,
};
Object.entries(dataset).forEach(([key, value]) => {
target.dataset[key] = value;
});
document.body.appendChild(target);
return target;
};
const allPopovers = () => wrapper.findAll(GlPopover);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('addPopovers', () => {
it('attaches popovers to the targets specified', async () => {
const target = createPopoverTarget();
await buildWrapper(target);
expect(wrapper.find(GlPopover).props('target')).toBe(target);
});
it('does not attach a popover twice to the same element', async () => {
const target = createPopoverTarget();
buildWrapper(target);
wrapper.vm.addPopovers([target]);
await wrapper.vm.$nextTick();
expect(wrapper.findAll(GlPopover)).toHaveLength(1);
});
it('supports HTML content', async () => {
const content = 'content with <b>HTML</b>';
await buildWrapper(
createPopoverTarget({
content,
html: true,
}),
);
const html = wrapper.find(GlPopover).html();
expect(html).toContain(content);
});
it.each`
option | value
${'placement'} | ${'bottom'}
${'triggers'} | ${'manual'}
`('sets $option to $value when data-$option is set in target', async ({ option, value }) => {
await buildWrapper(createPopoverTarget({ [option]: value }));
expect(wrapper.find(GlPopover).props(option)).toBe(value);
});
});
describe('dispose', () => {
it('removes all popovers when elements is nil', async () => {
await buildWrapper(createPopoverTarget(), createPopoverTarget());
wrapper.vm.dispose();
await wrapper.vm.$nextTick();
expect(allPopovers()).toHaveLength(0);
});
it('removes the popovers that target the elements specified', async () => {
const target = createPopoverTarget();
await buildWrapper(target, createPopoverTarget());
wrapper.vm.dispose(target);
await wrapper.vm.$nextTick();
expect(allPopovers()).toHaveLength(1);
});
});
describe('observe', () => {
it('removes popover when target is removed from the document', async () => {
const target = createPopoverTarget();
await buildWrapper(target);
wrapper.vm.addPopovers([target, createPopoverTarget()]);
await wrapper.vm.$nextTick();
triggerMutate(document.body, {
entry: { removedNodes: [target] },
options: { childList: true },
});
await wrapper.vm.$nextTick();
expect(allPopovers()).toHaveLength(1);
});
});
it('disconnects mutation observer on beforeDestroy', async () => {
await buildWrapper(createPopoverTarget());
expect(observersCount()).toBe(1);
wrapper.destroy();
expect(observersCount()).toBe(0);
});
});
import { initPopovers, dispose, destroy } from '~/popovers';
describe('popovers/index.js', () => {
let popoversApp;
const createPopoverTarget = (trigger = 'hover') => {
const target = document.createElement('button');
const dataset = {
title: 'default title',
content: 'some content',
toggle: 'popover',
trigger,
};
Object.entries(dataset).forEach(([key, value]) => {
target.dataset[key] = value;
});
document.body.appendChild(target);
return target;
};
const buildPopoversApp = () => {
popoversApp = initPopovers('[data-toggle="popover"]');
};
const triggerEvent = (target, eventName = 'mouseenter') => {
const event = new Event(eventName);
target.dispatchEvent(event);
};
afterEach(() => {
document.body.innerHTML = '';
destroy();
});
describe('initPopover', () => {
it('attaches a GlPopover for the elements specified in the selector', async () => {
const target = createPopoverTarget();
buildPopoversApp();
triggerEvent(target);
await popoversApp.$nextTick();
const html = document.querySelector('.gl-popover').innerHTML;
expect(document.querySelector('.gl-popover')).not.toBe(null);
expect(html).toContain('default title');
expect(html).toContain('some content');
});
it('supports triggering a popover via custom events', async () => {
const trigger = 'click';
const target = createPopoverTarget(trigger);
buildPopoversApp();
triggerEvent(target, trigger);
await popoversApp.$nextTick();
expect(document.querySelector('.gl-popover')).not.toBe(null);
expect(document.querySelector('.gl-popover').innerHTML).toContain('default title');
});
it('inits popovers on targets added after content load', async () => {
buildPopoversApp();
expect(document.querySelector('.gl-popover')).toBe(null);
const trigger = 'click';
const target = createPopoverTarget(trigger);
triggerEvent(target, trigger);
await popoversApp.$nextTick();
expect(document.querySelector('.gl-popover')).not.toBe(null);
});
});
describe('dispose', () => {
it('removes popovers that target the elements specified', async () => {
const fakeTarget = createPopoverTarget();
const target = createPopoverTarget();
buildPopoversApp();
triggerEvent(target);
triggerEvent(createPopoverTarget());
await popoversApp.$nextTick();
expect(document.querySelectorAll('.gl-popover')).toHaveLength(2);
dispose([fakeTarget]);
await popoversApp.$nextTick();
expect(document.querySelectorAll('.gl-popover')).toHaveLength(2);
dispose([target]);
await popoversApp.$nextTick();
expect(document.querySelectorAll('.gl-popover')).toHaveLength(1);
});
});
});
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