Commit 71d74443 authored by Simon Knox's avatar Simon Knox

Merge branch 'dispose-tooltip-instances' into 'master'

GitLab UI tooltips in HAML/Vanilla JS: Dispose tooltips

See merge request gitlab-org/gitlab!39379
parents 26536559 f8dc00b4
...@@ -36,14 +36,45 @@ export default { ...@@ -36,14 +36,45 @@ export default {
tooltips: [], tooltips: [],
}; };
}, },
created() {
this.observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
this.dispose(mutation.removedNodes);
});
});
},
beforeDestroy() {
this.observer.disconnect();
},
methods: { methods: {
addTooltips(elements, config) { addTooltips(elements, config) {
const newTooltips = elements const newTooltips = elements
.filter(element => !this.tooltipExists(element)) .filter(element => !this.tooltipExists(element))
.map(element => newTooltip(element, config)); .map(element => newTooltip(element, config));
newTooltips.forEach(tooltip => this.observe(tooltip));
this.tooltips.push(...newTooltips); this.tooltips.push(...newTooltips);
}, },
observe(tooltip) {
this.observer.observe(tooltip.target.parentElement, {
childList: true,
});
},
dispose(elements) {
if (!elements) {
this.tooltips = [];
return;
}
elements.forEach(element => {
const index = this.tooltips.findIndex(tooltip => tooltip.target === element);
if (index > -1) {
this.tooltips.splice(index, 1);
}
});
},
tooltipExists(element) { tooltipExists(element) {
return this.tooltips.some(tooltip => tooltip.target === element); return this.tooltips.some(tooltip => tooltip.target === element);
}, },
......
...@@ -10,9 +10,15 @@ const EVENTS_MAP = { ...@@ -10,9 +10,15 @@ const EVENTS_MAP = {
}; };
const DEFAULT_TRIGGER = 'hover focus'; const DEFAULT_TRIGGER = 'hover focus';
const APP_ELEMENT_ID = 'gl-tooltips-app';
const tooltipsApp = () => { const tooltipsApp = () => {
if (!app) { if (!app) {
const container = document.createElement('div');
container.setAttribute('id', APP_ELEMENT_ID);
document.body.appendChild(container);
app = new Vue({ app = new Vue({
render(h) { render(h) {
return h(Tooltips, { return h(Tooltips, {
...@@ -22,7 +28,7 @@ const tooltipsApp = () => { ...@@ -22,7 +28,7 @@ const tooltipsApp = () => {
ref: 'tooltips', ref: 'tooltips',
}); });
}, },
}).$mount(); }).$mount(container);
} }
return app; return app;
...@@ -56,3 +62,12 @@ export const initTooltips = (selector, config = {}) => { ...@@ -56,3 +62,12 @@ export const initTooltips = (selector, config = {}) => {
return tooltipsApp(); return tooltipsApp();
}; };
export const dispose = elements => {
return tooltipsApp().$refs.tooltips.dispose(elements);
};
export const destroy = () => {
tooltipsApp().$destroy();
app = null;
};
...@@ -84,7 +84,9 @@ const useMockObserver = (key, createMock) => { ...@@ -84,7 +84,9 @@ const useMockObserver = (key, createMock) => {
mockObserver.$_triggerObserve(...args); mockObserver.$_triggerObserve(...args);
}; };
return { trigger }; const observersCount = () => mockObserver.$_observers.length;
return { trigger, observersCount };
}; };
export const useMockIntersectionObserver = () => export const useMockIntersectionObserver = () =>
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlTooltip } from '@gitlab/ui'; import { GlTooltip } from '@gitlab/ui';
import { useMockMutationObserver } from 'helpers/mock_dom_observer';
import Tooltips from '~/tooltips/components/tooltips.vue'; import Tooltips from '~/tooltips/components/tooltips.vue';
describe('tooltips/components/tooltips.vue', () => { describe('tooltips/components/tooltips.vue', () => {
const { trigger: triggerMutate, observersCount } = useMockMutationObserver();
let wrapper; let wrapper;
const buildWrapper = () => { const buildWrapper = () => {
...@@ -20,11 +22,16 @@ describe('tooltips/components/tooltips.vue', () => { ...@@ -20,11 +22,16 @@ describe('tooltips/components/tooltips.vue', () => {
target.setAttribute(name, defaults[name]); target.setAttribute(name, defaults[name]);
}); });
document.body.appendChild(target);
return target; return target;
}; };
const allTooltips = () => wrapper.findAll(GlTooltip);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
describe('addTooltips', () => { describe('addTooltips', () => {
...@@ -91,4 +98,63 @@ describe('tooltips/components/tooltips.vue', () => { ...@@ -91,4 +98,63 @@ describe('tooltips/components/tooltips.vue', () => {
}, },
); );
}); });
describe('dispose', () => {
beforeEach(() => {
buildWrapper();
});
it('removes all tooltips when elements is nil', async () => {
wrapper.vm.addTooltips([createTooltipTarget(), createTooltipTarget()]);
await wrapper.vm.$nextTick();
wrapper.vm.dispose();
await wrapper.vm.$nextTick();
expect(allTooltips()).toHaveLength(0);
});
it('removes the tooltips that target the elements specified', async () => {
const target = createTooltipTarget();
wrapper.vm.addTooltips([target, createTooltipTarget()]);
await wrapper.vm.$nextTick();
wrapper.vm.dispose([target]);
await wrapper.vm.$nextTick();
expect(allTooltips()).toHaveLength(1);
});
});
describe('observe', () => {
beforeEach(() => {
buildWrapper();
});
it('removes tooltip when target is removed from the document', async () => {
const target = createTooltipTarget();
wrapper.vm.addTooltips([target, createTooltipTarget()]);
await wrapper.vm.$nextTick();
triggerMutate(document.body, {
entry: { removedNodes: [target] },
options: { childList: true },
});
await wrapper.vm.$nextTick();
expect(allTooltips()).toHaveLength(1);
});
});
it('disconnects mutation observer on beforeDestroy', () => {
buildWrapper();
wrapper.vm.addTooltips([createTooltipTarget()]);
expect(observersCount()).toBe(1);
wrapper.destroy();
expect(observersCount()).toBe(0);
});
}); });
import { initTooltips } from '~/tooltips'; import { initTooltips, dispose, destroy } from '~/tooltips';
describe('tooltips/index.js', () => { describe('tooltips/index.js', () => {
let tooltipsApp;
const createTooltipTarget = () => { const createTooltipTarget = () => {
const target = document.createElement('button'); const target = document.createElement('button');
const attributes = { const attributes = {
...@@ -13,22 +15,31 @@ describe('tooltips/index.js', () => { ...@@ -13,22 +15,31 @@ describe('tooltips/index.js', () => {
target.classList.add('has-tooltip'); target.classList.add('has-tooltip');
document.body.appendChild(target);
return target; return target;
}; };
const buildTooltipsApp = () => {
tooltipsApp = initTooltips('.has-tooltip');
};
const triggerEvent = (target, eventName = 'mouseenter') => { const triggerEvent = (target, eventName = 'mouseenter') => {
const event = new Event(eventName); const event = new Event(eventName);
target.dispatchEvent(event); target.dispatchEvent(event);
}; };
afterEach(() => {
document.body.childNodes.forEach(node => node.remove());
destroy();
});
describe('initTooltip', () => { describe('initTooltip', () => {
it('attaches a GlTooltip for the elements specified in the selector', async () => { it('attaches a GlTooltip for the elements specified in the selector', async () => {
const target = createTooltipTarget(); const target = createTooltipTarget();
const tooltipsApp = initTooltips('.has-tooltip');
document.body.appendChild(tooltipsApp.$el); buildTooltipsApp();
document.body.appendChild(target);
triggerEvent(target); triggerEvent(target);
...@@ -40,13 +51,8 @@ describe('tooltips/index.js', () => { ...@@ -40,13 +51,8 @@ describe('tooltips/index.js', () => {
it('supports triggering a tooltip in custom events', async () => { it('supports triggering a tooltip in custom events', async () => {
const target = createTooltipTarget(); const target = createTooltipTarget();
const tooltipsApp = initTooltips('.has-tooltip', {
triggers: 'click',
});
document.body.appendChild(tooltipsApp.$el);
document.body.appendChild(target);
buildTooltipsApp();
triggerEvent(target, 'click'); triggerEvent(target, 'click');
await tooltipsApp.$nextTick(); await tooltipsApp.$nextTick();
...@@ -55,4 +61,23 @@ describe('tooltips/index.js', () => { ...@@ -55,4 +61,23 @@ describe('tooltips/index.js', () => {
expect(document.querySelector('.gl-tooltip').innerHTML).toContain('default title'); expect(document.querySelector('.gl-tooltip').innerHTML).toContain('default title');
}); });
}); });
describe('dispose', () => {
it('removes tooltips that target the elements specified', async () => {
const target = createTooltipTarget();
buildTooltipsApp();
triggerEvent(target);
await tooltipsApp.$nextTick();
expect(document.querySelector('.gl-tooltip')).not.toBe(null);
dispose([target]);
await tooltipsApp.$nextTick();
expect(document.querySelector('.gl-tooltip')).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