fix(webapp): added timer (#8658)

This commit is contained in:
sebastian2357 2025-06-19 13:37:03 +02:00 committed by GitHub
parent 915091286a
commit 2b457a5823
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 219 additions and 30 deletions

View File

@ -15,8 +15,9 @@
:show-avatar="showAvatar"
:date-time="dateTime"
:show-popover="showPopover"
:injectedText="injectedText"
:injectedDate="injectedDate"
:injected-text="injectedText"
:injected-date="injectedDate"
:hover-delay="hoverDelay"
@close="closeMenu"
/>
</client-only>
@ -45,6 +46,7 @@ export default {
showPopover: { type: Boolean, default: true },
injectedText: { type: String, default: null },
injectedDate: { type: Boolean, default: false },
hoverDelay: { type: Number, default: 500 },
},
computed: {
...mapGetters({

View File

@ -1,4 +1,4 @@
import { render } from '@testing-library/vue'
import { render, waitFor, fireEvent } from '@testing-library/vue'
import { RouterLinkStub } from '@vue/test-utils'
import UserTeaserHelper from './UserTeaserHelper.vue'
@ -20,6 +20,7 @@ describe('UserTeaserHelper', () => {
withLinkToProfile = true,
onTouchScreen = false,
withPopoverEnabled = true,
hoverDelay = 500,
}) => {
mockIsTouchDevice = onTouchScreen
@ -29,13 +30,109 @@ describe('UserTeaserHelper', () => {
userLink,
linkToProfile: withLinkToProfile,
showPopover: withPopoverEnabled,
hoverDelay: hoverDelay,
},
stubs: {
NuxtLink: RouterLinkStub,
},
slots: {
default: '<div>Test Content</div>',
},
})
}
// Helper function for tests that need timers
const withFakeTimers = (testFn) => {
return async () => {
jest.useFakeTimers()
try {
await testFn()
} finally {
jest.runOnlyPendingTimers()
jest.useRealTimers()
}
}
}
describe('hover delay functionality', () => {
it(
'should emit open-menu after hover delay on desktop',
withFakeTimers(async () => {
const wrapper = Wrapper({
withLinkToProfile: true,
onTouchScreen: false,
withPopoverEnabled: true,
hoverDelay: 1000,
})
// Find the NuxtLink stub element
const link = wrapper.container.firstChild
expect(link).toBeTruthy()
// Trigger mouseenter
fireEvent.mouseEnter(link)
// Menu should not be opened yet
expect(wrapper.emitted()['open-menu']).toBeFalsy()
// Advance time
jest.advanceTimersByTime(1000)
// Now open-menu should have been emitted
await waitFor(() => {
expect(wrapper.emitted()['open-menu']).toBeTruthy()
expect(wrapper.emitted()['open-menu']).toHaveLength(1)
})
}),
)
it(
'should not emit open-menu if mouse leaves before delay',
withFakeTimers(async () => {
const wrapper = Wrapper({
withLinkToProfile: true,
onTouchScreen: false,
withPopoverEnabled: true,
hoverDelay: 1000,
})
const link = wrapper.container.firstChild
// Mouseenter + mouseleave
fireEvent.mouseEnter(link)
jest.advanceTimersByTime(500)
fireEvent.mouseLeave(link)
// Let the rest of the time pass
jest.advanceTimersByTime(500)
// open-menu should not have been emitted
expect(wrapper.emitted()['open-menu']).toBeFalsy()
// But close-menu should have been emitted
expect(wrapper.emitted()['close-menu']).toBeTruthy()
expect(wrapper.emitted()['close-menu']).toHaveLength(1)
}),
)
it('should emit open-menu immediately on touch device button click', async () => {
const wrapper = Wrapper({
withLinkToProfile: true,
onTouchScreen: true,
withPopoverEnabled: true,
})
const button = wrapper.container.querySelector('button')
expect(button).toBeTruthy()
// Click on button
fireEvent.click(button)
// Should be emitted immediately, without timer
expect(wrapper.emitted()['open-menu']).toBeTruthy()
expect(wrapper.emitted()['open-menu']).toHaveLength(1)
})
})
describe('with linkToProfile and popover enabled, on touch screen', () => {
let wrapper
beforeEach(() => {
@ -43,6 +140,7 @@ describe('UserTeaserHelper', () => {
})
it('renders button', () => {
expect(wrapper.container.querySelector('button')).toBeTruthy()
expect(wrapper.container).toMatchSnapshot()
})
})
@ -54,6 +152,7 @@ describe('UserTeaserHelper', () => {
})
it('renders span', () => {
expect(wrapper.container.querySelector('span')).toBeTruthy()
expect(wrapper.container).toMatchSnapshot()
})
})
@ -65,7 +164,55 @@ describe('UserTeaserHelper', () => {
})
it('renders link', () => {
const routerLinkStub = wrapper.container.firstChild
expect(routerLinkStub).toBeTruthy()
expect(routerLinkStub.tagName).toBeTruthy()
expect(wrapper.container).toMatchSnapshot()
})
})
describe('timer cleanup', () => {
it(
'should clear timer on component unmount',
withFakeTimers(async () => {
const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout')
const wrapper = Wrapper({
withLinkToProfile: true,
onTouchScreen: false,
withPopoverEnabled: true,
hoverDelay: 5000,
})
// Start hover timer
fireEvent.mouseEnter(wrapper.container.firstChild)
// Unmount while timer is running
wrapper.unmount()
// clearTimeout should have been called
expect(clearTimeoutSpy).toHaveBeenCalled()
clearTimeoutSpy.mockRestore()
}),
)
})
describe('popover behavior without showPopover', () => {
it('should not emit events when showPopover is false', () => {
const wrapper = Wrapper({
withLinkToProfile: true,
onTouchScreen: false,
withPopoverEnabled: false,
})
const link = wrapper.container.firstChild
fireEvent.mouseEnter(link)
fireEvent.mouseLeave(link)
expect(wrapper.emitted()['open-menu']).toBeUndefined()
expect(wrapper.emitted()['close-menu']).toBeUndefined()
})
})
})

View File

@ -4,16 +4,16 @@
</button>
<span
v-else-if="!linkToProfile || !userLink"
@mouseover="() => showPopover && openMenu()"
@mouseleave="closeMenu"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<slot />
</span>
<nuxt-link
v-else
:to="userLink"
@mouseover.native="() => showPopover && openMenu()"
@mouseleave.native="closeMenu"
@mouseenter.native="handleMouseEnter"
@mouseleave.native="handleMouseLeave"
>
<slot />
</nuxt-link>
@ -28,6 +28,13 @@ export default {
userLink: { type: Object, default: null },
linkToProfile: { type: Boolean, default: true },
showPopover: { type: Boolean, default: false },
hoverDelay: { type: Number, default: 500 },
},
data() {
return {
hoverTimer: null,
isHovering: false,
}
},
computed: {
isTouchDevice() {
@ -35,12 +42,46 @@ export default {
},
},
methods: {
handleMouseEnter() {
if (!this.showPopover) return
this.isHovering = true
this.clearHoverTimer()
this.hoverTimer = setTimeout(() => {
// Only open if still hovering
if (this.isHovering) {
this.openMenu()
}
}, this.hoverDelay)
},
handleMouseLeave() {
if (!this.showPopover) return
this.isHovering = false
this.clearHoverTimer()
this.closeMenu()
},
clearHoverTimer() {
if (this.hoverTimer) {
clearTimeout(this.hoverTimer)
this.hoverTimer = null
}
},
openMenu() {
this.$emit('open-menu')
},
closeMenu() {
this.$emit('close-menu')
},
},
beforeDestroy() {
this.clearHoverTimer()
},
}
</script>

View File

@ -6,6 +6,7 @@
:link-to-profile="linkToProfile"
:show-popover="showPopover"
:user-link="userLink"
:hover-delay="hoverDelay"
@open-menu="loadPopover(openMenu)"
@close-menu="closeMenu(false)"
data-test="avatarUserLink"
@ -18,6 +19,7 @@
:link-to-profile="linkToProfile"
:show-popover="showPopover"
:user-link="userLink"
:hover-delay="hoverDelay"
@open-menu="loadPopover(openMenu)"
@close-menu="closeMenu(false)"
>
@ -67,7 +69,7 @@ import UserTeaserPopover from './UserTeaserPopover'
import UserTeaserHelper from './UserTeaserHelper.vue'
export default {
name: 'UserTeaser',
name: 'UserTeaserNonAnonymous',
components: {
ProfileAvatar,
UserTeaserPopover,
@ -85,6 +87,7 @@ export default {
showPopover: { type: Boolean, default: true },
injectedText: { type: String, default: null },
injectedDate: { type: Boolean, default: false },
hoverDelay: { type: Number, default: 500 },
},
computed: {
...mapGetters({

View File

@ -353,7 +353,6 @@ exports[`UserTeaser given an user with linkToProfile, on desktop when hovering t
delay="0"
handleresize="true"
offset="16"
open="true"
openclass="open"
opengroup="0"
placement="bottom-end"
@ -421,12 +420,7 @@ exports[`UserTeaser given an user with linkToProfile, on desktop when hovering t
<!---->
</div>
<div>
<user-teaser-popover-stub
user-id="user1"
user-link="[object Object]"
/>
</div>
<div />
</v-popover-stub>
</client-only-stub>
</div>
@ -717,7 +711,6 @@ exports[`UserTeaser given an user without linkToProfile, on desktop when hoverin
delay="0"
handleresize="true"
offset="16"
open="true"
openclass="open"
opengroup="0"
placement="bottom-end"
@ -785,11 +778,7 @@ exports[`UserTeaser given an user without linkToProfile, on desktop when hoverin
<!---->
</div>
<div>
<user-teaser-popover-stub
user-id="user1"
/>
</div>
<div />
</v-popover-stub>
</client-only-stub>
</div>
@ -810,7 +799,6 @@ exports[`UserTeaser given an user without linkToProfile, on desktop when hoverin
delay="0"
handleresize="true"
offset="16"
open="true"
openclass="open"
opengroup="0"
placement="bottom-end"
@ -878,11 +866,7 @@ exports[`UserTeaser given an user without linkToProfile, on desktop when hoverin
<!---->
</div>
<div>
<user-teaser-popover-stub
user-id="user1"
/>
</div>
<div />
</v-popover-stub>
</client-only-stub>
</div>

View File

@ -2,18 +2,30 @@
exports[`UserTeaserHelper with linkToProfile and popover enabled, on touch screen renders button 1`] = `
<div>
<button />
<button>
<div>
Test Content
</div>
</button>
</div>
`;
exports[`UserTeaserHelper with linkToProfile, on desktop renders link 1`] = `
<div>
<a />
<a>
<div>
Test Content
</div>
</a>
</div>
`;
exports[`UserTeaserHelper without linkToProfile renders span 1`] = `
<div>
<span />
<span>
<div>
Test Content
</div>
</span>
</div>
`;