diff --git a/changelog/entries/unreleased/bug/3501_hide_tooltip_when_value_dynamically_changes_to_null.json b/changelog/entries/unreleased/bug/3501_hide_tooltip_when_value_dynamically_changes_to_null.json new file mode 100644 index 0000000000..2d0b7c34d4 --- /dev/null +++ b/changelog/entries/unreleased/bug/3501_hide_tooltip_when_value_dynamically_changes_to_null.json @@ -0,0 +1,8 @@ +{ + "type": "bug", + "message": "Hide tooltip when value dynamically changes to null", + "domain": "database", + "issue_number": 3501, + "bullet_points": [], + "created_at": "2025-10-30" +} diff --git a/web-frontend/modules/core/directives/tooltip.js b/web-frontend/modules/core/directives/tooltip.js index 24f181ab9d..a3d3aeecc5 100644 --- a/web-frontend/modules/core/directives/tooltip.js +++ b/web-frontend/modules/core/directives/tooltip.js @@ -65,6 +65,11 @@ export default { el.tooltipOptions = getOptions(el, binding) el.onClickOutsideCallback = null + if (el._tooltipEventsAdded) { + return + } + el._tooltipEventsAdded = true + el.updatePositionEvent = (event) => { const rect = el.getBoundingClientRect() const position = el.getAttribute('tooltip-position') || 'bottom' @@ -162,6 +167,11 @@ export default { if (el.tooltipOptions.contentIsHtml) { el.tooltipContentElement.innerHTML = el.tooltipOptions.value } else { + while (el.tooltipContentElement.firstChild) { + el.tooltipContentElement.removeChild( + el.tooltipContentElement.firstChild + ) + } el.tooltipContentElement.appendChild( document.createTextNode(el.tooltipOptions.value) ) @@ -179,17 +189,20 @@ export default { el.removeTimeout() // make tooltip content preserved if pointer hovers - el.tooltipContentElement.addEventListener('mouseenter', el.removeTimeout) - el.tooltipContentElement.addEventListener( - 'mouseleave', - el.tooltipMoveLeaveEvent - ) + // Only add event listeners if they haven't been added yet + if (!el._tooltipContentEventsAdded) { + el._tooltipContentEventsAdded = true + el.tooltipContentElement.addEventListener( + 'mouseenter', + el.removeTimeout + ) + el.tooltipContentElement.addEventListener( + 'mouseleave', + el.tooltipMoveLeaveEvent + ) + } window.addEventListener('mousemove', el.tooltipMouseMoveEvent) - - // When the user scrolls or resizes the window it could be possible that the - // element where the tooltip is anchored to has moved, so then the position - // needs to be updated. We only want to do this when the tooltip is visible. window.addEventListener('scroll', el.updatePositionEvent, true) window.addEventListener('resize', el.updatePositionEvent) @@ -211,12 +224,9 @@ export default { * visible, if duration is > 0. */ el.tooltipMoveLeaveEvent = () => { - // we should remove any pending timeout before setting new one, because timeout - // should be counted from the last mouse leave event. el.removeTimeout() el.tooltipTimeout = setTimeout( el.tooltipClose, - // timeout from caller is in seconds. remember to convert to mseconds el.tooltipOptions.duration * 1000 ) } @@ -230,13 +240,15 @@ export default { * actually closing the tooltip here */ el.tooltipClose = () => { - // cleanup actions: remove window handlers set with onClickOutside() el.removeTooltipOutsideClickCallback() if (el.tooltipElement) { - el.tooltipElement.parentNode.removeChild(el.tooltipElement) + if (el.tooltipElement.parentNode) { + el.tooltipElement.parentNode.removeChild(el.tooltipElement) + } el.tooltipElement = null el.tooltipContentElement = null + el._tooltipContentEventsAdded = false } window.removeEventListener('mousemove', el.tooltipMouseMoveEvent) @@ -244,7 +256,7 @@ export default { window.removeEventListener('resize', el.updatePositionEvent) el.removeTimeout() } - // those event listeners should be bind all the time to the el element + el.addEventListener('mouseenter', el.tooltipMouseEnterEvent) el.addEventListener('mouseleave', el.tooltipMoveLeaveEvent) }, @@ -257,10 +269,17 @@ export default { if (el.tooltipElement && el.tooltipElement.parentNode) { el.tooltipElement.parentNode.removeChild(el.tooltipElement) } + + if (el._tooltipEventsAdded) { + el.removeEventListener('mouseenter', el.tooltipMouseEnterEvent) + el.removeEventListener('mouseleave', el.tooltipMoveLeaveEvent) + el._tooltipEventsAdded = false + } + el.tooltipElement = null el.tooltipContentElement = null - el.removeEventListener('mouseenter', el.tooltipMouseEnterEvent) - el.removeEventListener('mouseleave', el.tooltipMoveLeaveEvent) + el._tooltipContentEventsAdded = false + window.removeEventListener('scroll', el.updatePositionEvent, true) window.removeEventListener('resize', el.updatePositionEvent) }, diff --git a/web-frontend/stories/ButtonText.stories.mdx b/web-frontend/stories/ButtonText.stories.mdx index 3d82e320f9..1e48dbc437 100644 --- a/web-frontend/stories/ButtonText.stories.mdx +++ b/web-frontend/stories/ButtonText.stories.mdx @@ -3,6 +3,7 @@ import { config, withDesign } from 'storybook-addon-designs' import { action } from '@storybook/addon-actions' import ButtonText from '@baserow/modules/core/components/ButtonText' +import tooltip from '@baserow/modules/core/directives/tooltip' + +## Tooltip Test + +export const TooltipTemplate = (args, { argTypes }) => ({ + components: { ButtonText }, + directives: { tooltip }, + data() { + return { + test: false + } + }, + template: ` +
+ + test {{ test ? 'without tooltip' : 'with tooltip' }} + +

+ +
+ `, +}) + + + + {TooltipTemplate.bind({})} + + diff --git a/web-frontend/test/unit/core/components/tooltip.spec.js b/web-frontend/test/unit/core/components/tooltip.spec.js new file mode 100644 index 0000000000..d6b9000a21 --- /dev/null +++ b/web-frontend/test/unit/core/components/tooltip.spec.js @@ -0,0 +1,60 @@ +import { shallowMount } from '@vue/test-utils' +import ButtonText from '@baserow/modules/core/components/ButtonText' +import tooltip from '@baserow/modules/core/directives/tooltip' + +describe('Tooltip directive', () => { + const Component = { + template: ` + + test {{ tooltipValue ? 'with tooltip' : 'without tooltip' }} + + `, + components: { ButtonText }, + directives: { tooltip }, + data() { + return { + tooltipValue: 'hello', + } + }, + } + + it('shows tooltip when value is provided', async () => { + const wrapper = shallowMount(Component) + const buttonText = wrapper.findComponent(ButtonText) + + await buttonText.trigger('mouseenter') + + expect(document.querySelector('.tooltip')).toBeTruthy() + }) + + it('hides tooltip when value is null', async () => { + const wrapper = shallowMount(Component) + const buttonText = wrapper.findComponent(ButtonText) + + await wrapper.setData({ tooltipValue: null }) + + await buttonText.trigger('mouseenter') + + expect(document.querySelector('.tooltip')).toBeFalsy() + }) + + it('dynamically shows/hides tooltip when value changes', async () => { + const wrapper = shallowMount(Component) + const buttonText = wrapper.findComponent(ButtonText) + + await buttonText.trigger('mouseenter') + expect(document.querySelector('.tooltip')).toBeTruthy() + + await wrapper.setData({ tooltipValue: null }) + expect(document.querySelector('.tooltip')).toBeFalsy() + + await wrapper.setData({ tooltipValue: 'new tooltip' }) + await buttonText.trigger('mouseenter') + expect(document.querySelector('.tooltip')).toBeTruthy() + }) + + afterEach(() => { + const tooltips = document.querySelectorAll('.tooltip') + tooltips.forEach((tooltip) => tooltip.remove()) + }) +})