feat(webapp): user teaser popover (#8450)

* calculate distance between current user and queried user

* fix query for unset location

* use database to calculate distance

* rename distance to distance to me, 100% calculation done in DB

* distanceToMe tests

* lint fixes

* remove comments

* Show user teaser popover with badges, Desktop

* Refactor UserTeaser and add mobile popover support

* Avoid click propagation (WIP)

* Prevent event propagation

* Adjust alignment and font sizes

* More spacing for statistics

* Add distance, simplify user link

* Refactor location info into own component

* Add tests for UserTeaserPopup

* Refactor and test LocationInfo

* Query distanceToMe, rename distance to distanceToMe

* Update test

* Improve tests for UserTeaser, WIP

* Fix tests

* DistanceToMe on User instead of Location

* Revert "DistanceToMe on User instead of Location"

This reverts commit 96c9db00a44cd120e47bfe9534d3e066a194744c.

* Fix notifications

* Refactor UserTeaser and fix location info

* Fix group member crash

* Show 0 distance

* Fit in popover on small screens

* Allow access to profile on desktop

* Revert backend changes

* Load user teaser popover data only when needed

* Fix type mismatch

* Refactor for clarity and accessibility

* Litte refactorings and improvements

* Fix popover test

* Adapt and fix tests

* Fix tests and bugs

* Add placeholder

* cypress: adapt user teaser locator to changes

* Remove delays and scrolling

* Disable popovers in notification list and fix layout

* Remove flickering

* Make overlay catch all pointer events on touch devices

* Re-add attribute for E2E test

* Fix test, return to mouseover

* fix snapshot

---------

Co-authored-by: Ulf Gebhardt <ulf.gebhardt@webcraft-media.de>
Co-authored-by: Wolfgang Huß <wolle.huss@pjannto.com>
Co-authored-by: mahula <lenzmath@posteo.de>
This commit is contained in:
Max 2025-05-06 01:54:13 +02:00 committed by GitHub
parent 65f764f6d9
commit 33274e5b9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 4277 additions and 1365 deletions

View File

@ -8,6 +8,6 @@ defineStep('I should see my comment', () => {
.get('.profile-avatar img')
.should('have.attr', 'src')
.and('contain', 'https://') // some url
.get('.user-teaser > .info > .text')
.get('.user-teaser .info > .text')
.should('contain', 'today at')
})

View File

@ -150,7 +150,7 @@ $font-size-xx-large: 2rem;
$font-size-x-large: 1.5rem;
$font-size-large: 1.25rem;
$font-size-base: 1rem;
$font-size-body: 15px;
$font-size-body: 0.938rem;
$font-size-small: 0.8rem;
$font-size-x-small: 0.7rem;
$font-size-xx-small: 0.6rem;

View File

@ -52,11 +52,6 @@ $easeOut: cubic-bezier(0.19, 1, 0.22, 1);
background: #fff;
}
body.dropdown-open {
height: 100vh;
overflow: hidden;
}
blockquote {
display: block;
padding: 15px 20px 15px 45px;
@ -140,6 +135,10 @@ hr {
opacity: 1;
transition-delay: 0;
transition: opacity 80ms ease-out;
@media(hover: none) {
pointer-events: all;
}
}
}
@ -155,7 +154,6 @@ hr {
[class$='menu-popover'] {
min-width: 130px;
a,
button {
display: flex;
align-content: center;

View File

@ -43,32 +43,12 @@ export default {
},
watch: {
isPopoverOpen: {
immediate: true,
handler(isOpen) {
try {
if (isOpen) {
this.$nextTick(() => {
setTimeout(() => {
const paddingRightStyle = `${
window.innerWidth - document.documentElement.clientWidth
}px`
const navigationElement = document.querySelector('.main-navigation')
document.body.style.paddingRight = paddingRightStyle
document.body.classList.add('dropdown-open')
if (navigationElement) {
navigationElement.style.paddingRight = paddingRightStyle
}
}, 20)
})
} else {
const navigationElement = document.querySelector('.main-navigation')
document.body.style.paddingRight = null
document.body.classList.remove('dropdown-open')
if (navigationElement) {
navigationElement.style.paddingRight = null
}
}
} catch (err) {}
if (isOpen) {
document.body.classList.add('dropdown-open')
} else {
document.body.classList.remove('dropdown-open')
}
},
},
},

View File

@ -4,6 +4,7 @@
<user-teaser
:user="isGroup ? notification.relatedUser : from.author"
:date-time="from.createdAt"
:show-popover="false"
/>
</client-only>
<p class="description">{{ $t(`notifications.reason.${notification.reason}`) }}</p>

View File

@ -127,7 +127,7 @@ export default {
apollo: {
notifications: {
query() {
return notificationQuery(this.$i18n)
return notificationQuery()
},
variables() {
return {

View File

@ -88,7 +88,7 @@ describe('NotificationsTable.vue', () => {
})
it('renders the author', () => {
const userinfo = firstRowNotification.find('.user-teaser > .info')
const userinfo = firstRowNotification.find('.user-teaser .info')
expect(userinfo.text()).toContain(postNotification.from.author.name)
})
@ -121,7 +121,7 @@ describe('NotificationsTable.vue', () => {
})
it('renders the author', () => {
const userinfo = secondRowNotification.find('.user-teaser > .info')
const userinfo = secondRowNotification.find('.user-teaser .info')
expect(userinfo.text()).toContain(commentNotification.from.author.name)
})

View File

@ -0,0 +1,31 @@
import { render } from '@testing-library/vue'
import LocationInfo from './LocationInfo.vue'
const localVue = global.localVue
describe('LocationInfo', () => {
const Wrapper = ({ withDistance }) => {
return render(LocationInfo, {
localVue,
propsData: {
locationData: {
name: 'Paris',
distanceToMe: withDistance ? 100 : null,
},
},
mocks: {
$t: jest.fn((t) => t),
},
})
}
it('renders with distance', () => {
const wrapper = Wrapper({ withDistance: true })
expect(wrapper.container).toMatchSnapshot()
})
it('renders without distance', () => {
const wrapper = Wrapper({ withDistance: false })
expect(wrapper.container).toMatchSnapshot()
})
})

View File

@ -0,0 +1,44 @@
<template>
<div class="location-info">
<div class="location">
<base-icon name="map-marker" />
{{ locationData.name }}
</div>
<div v-if="distance" class="distance">{{ distance }}</div>
</div>
</template>
<script>
export default {
name: 'LocationInfo',
props: {
locationData: { type: Object, default: null },
},
computed: {
distance() {
return this.locationData.distanceToMe === null
? null
: this.$t('location.distance', { distance: this.locationData.distanceToMe })
},
},
}
</script>
<style scoped>
.location-info {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.location {
display: flex;
align-items: center;
justify-content: center;
}
.distance {
margin-top: 8px;
}
}
</style>

View File

@ -1,113 +1,250 @@
import { mount, RouterLinkStub } from '@vue/test-utils'
import { render, screen, fireEvent } from '@testing-library/vue'
import { RouterLinkStub } from '@vue/test-utils'
import UserTeaser from './UserTeaser.vue'
import Vuex from 'vuex'
const localVue = global.localVue
const filter = jest.fn((str) => str)
localVue.filter('truncate', filter)
// Mock Math.random, used in Dropdown
Object.assign(Math, {
random: () => 0,
})
const waitForPopover = async () => await new Promise((resolve) => setTimeout(resolve, 1000))
let mockIsTouchDevice
jest.mock('../utils/isTouchDevice', () => ({
isTouchDevice: jest.fn(() => mockIsTouchDevice),
}))
const userTilda = {
name: 'Tilda Swinton',
slug: 'tilda-swinton',
id: 'user1',
avatar: '/avatars/tilda-swinton',
badgeVerification: {
id: 'bv1',
icon: '/icons/verified',
description: 'Verified',
isDefault: false,
},
badgeTrophiesSelected: [
{
id: 'trophy1',
icon: '/icons/trophy1',
description: 'Trophy 1',
isDefault: false,
},
{
id: 'trophy2',
icon: '/icons/trophy2',
description: 'Trophy 2',
isDefault: false,
},
{
id: 'empty',
icon: '/icons/empty',
description: 'Empty',
isDefault: true,
},
],
}
describe('UserTeaser', () => {
let propsData
let mocks
let stubs
let getters
const Wrapper = ({
isModerator = false,
withLinkToProfile = true,
onTouchScreen = false,
withAvatar = true,
user = userTilda,
withPopoverEnabled = true,
}) => {
mockIsTouchDevice = onTouchScreen
beforeEach(() => {
propsData = {}
mocks = {
$t: jest.fn(),
}
stubs = {
NuxtLink: RouterLinkStub,
}
getters = {
'auth/user': () => {
return {}
const store = new Vuex.Store({
getters: {
'auth/user': () => {
return {}
},
'auth/isModerator': () => isModerator,
},
'auth/isModerator': () => false,
}
})
return render(UserTeaser, {
localVue,
store,
propsData: {
user,
linkToProfile: withLinkToProfile,
showAvatar: withAvatar,
showPopover: withPopoverEnabled,
},
stubs: {
NuxtLink: RouterLinkStub,
'user-teaser-popover': true,
'v-popover': true,
'client-only': true,
},
mocks: {
$t: jest.fn((t) => t),
},
})
}
it('renders anonymous user', () => {
const wrapper = Wrapper({ user: null })
expect(wrapper.container).toMatchSnapshot()
})
describe('mount', () => {
const Wrapper = () => {
const store = new Vuex.Store({
getters,
describe('given an user', () => {
describe('without linkToProfile, on touch screen', () => {
let wrapper
beforeEach(() => {
wrapper = Wrapper({ withLinkToProfile: false, onTouchScreen: true })
})
return mount(UserTeaser, { store, propsData, mocks, stubs, localVue })
}
it('renders anonymous user', () => {
const wrapper = Wrapper()
expect(wrapper.text()).toBe('')
expect(mocks.$t).toHaveBeenCalledWith('profile.userAnonym')
it('renders', () => {
expect(wrapper.container).toMatchSnapshot()
})
describe('when clicking the user name', () => {
beforeEach(async () => {
const userName = screen.getByText('Tilda Swinton')
await fireEvent.click(userName)
await waitForPopover()
})
it('renders the popover', () => {
expect(wrapper.container).toMatchSnapshot()
})
})
describe('when clicking the user avatar', () => {
beforeEach(async () => {
const userAvatar = screen.getByAltText('Tilda Swinton')
await fireEvent.click(userAvatar)
await waitForPopover()
})
it('renders the popover', () => {
expect(wrapper.container).toMatchSnapshot()
})
})
})
describe('given an user', () => {
describe('with linkToProfile, on touch screen', () => {
let wrapper
beforeEach(() => {
propsData.user = {
name: 'Tilda Swinton',
slug: 'tilda-swinton',
}
wrapper = Wrapper({ withLinkToProfile: true, onTouchScreen: true })
})
it('renders user name', () => {
const wrapper = Wrapper()
expect(mocks.$t).not.toHaveBeenCalledWith('profile.userAnonym')
expect(wrapper.text()).toMatch('Tilda Swinton')
it('renders', () => {
expect(wrapper.container).toMatchSnapshot()
})
describe('user is deleted', () => {
beforeEach(() => {
propsData.user.deleted = true
describe('when clicking the user name', () => {
beforeEach(async () => {
const userName = screen.getByText('Tilda Swinton')
await fireEvent.click(userName)
})
it('renders the popover', () => {
expect(wrapper.container).toMatchSnapshot()
})
})
})
describe('without linkToProfile, on desktop', () => {
let wrapper
beforeEach(() => {
wrapper = Wrapper({ withLinkToProfile: false, onTouchScreen: false })
})
it('renders', () => {
expect(wrapper.container).toMatchSnapshot()
})
describe('when hovering the user name', () => {
beforeEach(async () => {
const userName = screen.getByText('Tilda Swinton')
await fireEvent.mouseOver(userName)
await waitForPopover()
})
it('renders the popover', () => {
expect(wrapper.container).toMatchSnapshot()
})
})
describe('when hovering the user avatar', () => {
beforeEach(async () => {
const userAvatar = screen.getByAltText('Tilda Swinton')
await fireEvent.mouseOver(userAvatar)
await waitForPopover()
})
it('renders the popover', () => {
expect(wrapper.container).toMatchSnapshot()
})
})
})
describe('with linkToProfile, on desktop', () => {
let wrapper
beforeEach(() => {
wrapper = Wrapper({ withLinkToProfile: true, onTouchScreen: false })
})
it('renders', () => {
expect(wrapper.container).toMatchSnapshot()
})
describe('when hovering the user name', () => {
beforeEach(async () => {
const userName = screen.getByText('Tilda Swinton')
await fireEvent.mouseOver(userName)
await waitForPopover()
})
it('renders the popover', () => {
expect(wrapper.container).toMatchSnapshot()
})
})
})
describe('avatar is disabled', () => {
it('does not render the avatar', () => {
const wrapper = Wrapper({ withAvatar: false })
expect(wrapper.container).toMatchSnapshot()
})
})
describe('user is deleted', () => {
it('renders anonymous user', () => {
const wrapper = Wrapper({ user: { ...userTilda, deleted: true } })
expect(wrapper.container).toMatchSnapshot()
})
describe('even if the current user is a moderator', () => {
it('renders anonymous user', () => {
const wrapper = Wrapper()
expect(wrapper.text()).not.toMatch('Tilda Swinton')
expect(mocks.$t).toHaveBeenCalledWith('profile.userAnonym')
})
describe('even if the current user is a moderator', () => {
beforeEach(() => {
getters['auth/isModerator'] = () => true
})
it('renders anonymous user', () => {
const wrapper = Wrapper()
expect(wrapper.text()).not.toMatch('Tilda Swinton')
expect(mocks.$t).toHaveBeenCalledWith('profile.userAnonym')
const wrapper = Wrapper({
user: { ...userTilda, deleted: true },
isModerator: true,
})
expect(wrapper.container).toMatchSnapshot()
})
})
})
describe('user is disabled', () => {
beforeEach(() => {
propsData.user.disabled = true
})
describe('user is disabled', () => {
it('renders anonymous user', () => {
const wrapper = Wrapper({ user: { ...userTilda, disabled: true } })
expect(wrapper.container).toMatchSnapshot()
})
it('renders anonymous user', () => {
const wrapper = Wrapper()
expect(wrapper.text()).not.toMatch('Tilda Swinton')
expect(mocks.$t).toHaveBeenCalledWith('profile.userAnonym')
})
describe('current user is a moderator', () => {
beforeEach(() => {
getters['auth/isModerator'] = () => true
})
it('renders user name', () => {
const wrapper = Wrapper()
expect(wrapper.text()).not.toMatch('Anonymous')
expect(wrapper.text()).toMatch('Tilda Swinton')
})
it('has "disabled-content" class', () => {
const wrapper = Wrapper()
expect(wrapper.classes()).toContain('disabled-content')
})
describe('current user is a moderator', () => {
it('renders user name', () => {
const wrapper = Wrapper({ user: { ...userTilda, disabled: true }, isModerator: true })
expect(wrapper.container).toMatchSnapshot()
})
})
})

View File

@ -4,57 +4,34 @@
<span class="info anonymous">{{ $t('profile.userAnonym') }}</span>
</div>
<div v-else :class="[{ 'disabled-content': user.disabled }]" placement="top-start">
<div :class="['user-teaser']">
<nuxt-link v-if="linkToProfile && showAvatar" :to="userLink" data-test="avatarUserLink">
<profile-avatar :profile="user" size="small" />
</nuxt-link>
<profile-avatar v-else-if="showAvatar" :profile="user" size="small" />
<div class="info flex-direction-column">
<div :class="wide ? 'flex-direction-row' : 'flex-direction-column'">
<nuxt-link v-if="linkToProfile" :to="userLink">
<span class="text">
<span class="slug">{{ userSlug }}</span>
<span class="name">{{ userName }}</span>
</span>
</nuxt-link>
<span v-else class="text">
<span class="slug">{{ userSlug }}</span>
<span class="name">{{ userName }}</span>
</span>
<span v-if="wide">&nbsp;</span>
<span v-if="group">
<span class="text">
{{ $t('group.in') }}
</span>
<nuxt-link :to="groupLink">
<span class="text">
<span class="slug">{{ groupSlug }}</span>
<span v-if="!userOnly" class="name">{{ groupName }}</span>
</span>
</nuxt-link>
</span>
</div>
<span v-if="!userOnly && dateTime" class="text">
<base-icon name="clock" />
<date-time :date-time="dateTime" />
<slot name="dateTime"></slot>
</span>
</div>
</div>
<!-- isTouchDevice only supported on client-->
<client-only>
<user-teaser-non-anonymous
v-if="user"
:link-to-profile="linkToProfile"
:user="user"
:group="group"
:wide="wide"
:show-avatar="showAvatar"
:date-time="dateTime"
:show-popover="showPopover"
@close="closeMenu"
/>
</client-only>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import DateTime from '~/components/DateTime'
import ProfileAvatar from '~/components/_new/generic/ProfileAvatar/ProfileAvatar'
import UserTeaserNonAnonymous from './UserTeaserNonAnonymous'
export default {
name: 'UserTeaser',
components: {
DateTime,
ProfileAvatar,
UserTeaserNonAnonymous,
},
props: {
linkToProfile: { type: Boolean, default: true },
@ -69,71 +46,36 @@ export default {
...mapGetters({
isModerator: 'auth/isModerator',
}),
itsMe() {
return this.user.slug === this.$store.getters['auth/user'].slug
},
displayAnonymous() {
const { user, isModerator } = this
return !user || user.deleted || (user.disabled && !isModerator)
},
userLink() {
const { id, slug } = this.user
if (!(id && slug)) return ''
return { name: 'profile-id-slug', params: { slug, id } }
},
userSlug() {
const { slug } = this.user || {}
return slug && `@${slug}`
},
userName() {
const { name } = this.user || {}
return name || this.$t('profile.userAnonym')
},
userOnly() {
return !this.dateTime && !this.group
},
groupLink() {
const { id, slug } = this.group
if (!(id && slug)) return ''
return { name: 'groups-id-slug', params: { slug, id } }
},
groupSlug() {
const { slug } = this.group || {}
return slug && `&${slug}`
},
groupName() {
const { name } = this.group || {}
return name || this.$t('profile.userAnonym')
},
},
methods: {
optimisticFollow({ followedByCurrentUser }) {
const inc = followedByCurrentUser ? 1 : -1
this.user.followedByCurrentUser = followedByCurrentUser
this.user.followedByCount += inc
},
updateFollow({ followedByCurrentUser, followedByCount }) {
this.user.followedByCount = followedByCount
this.user.followedByCurrentUser = followedByCurrentUser
closeMenu() {
this.$emit('close')
},
},
}
</script>
<style lang="scss">
.trigger {
max-width: 100%;
}
.user-teaser {
display: flex;
flex-wrap: nowrap;
> .profile-avatar {
.trigger {
max-width: 100%;
display: flex !important;
justify-content: center;
align-items: center;
}
.profile-avatar {
flex-shrink: 0;
}
> .info {
.info {
padding-left: $space-xx-small;
overflow: hidden;
text-overflow: ellipsis;
@ -145,12 +87,12 @@ export default {
.slug {
color: $color-primary;
font-size: $font-size-base;
font-size: calc(1.15 * $font-size-base);
}
.name {
color: $text-color-soft;
font-size: $font-size-small;
font-size: $font-size-base;
}
}
@ -158,12 +100,14 @@ export default {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
}
.flex-direction-row {
display: flex;
flex-direction: row;
justify-content: center;
padding-left: 2px;
}
.text {

View File

@ -0,0 +1,71 @@
import { render } from '@testing-library/vue'
import { RouterLinkStub } from '@vue/test-utils'
import UserTeaserHelper from './UserTeaserHelper.vue'
const localVue = global.localVue
const userLink = {
name: 'profile-id-slug',
params: { slug: 'slug', id: 'id' },
}
let mockIsTouchDevice
jest.mock('../utils/isTouchDevice', () => ({
isTouchDevice: jest.fn(() => mockIsTouchDevice),
}))
describe('UserTeaserHelper', () => {
const Wrapper = ({
withLinkToProfile = true,
onTouchScreen = false,
withPopoverEnabled = true,
}) => {
mockIsTouchDevice = onTouchScreen
return render(UserTeaserHelper, {
localVue,
propsData: {
userLink,
linkToProfile: withLinkToProfile,
showPopover: withPopoverEnabled,
},
stubs: {
NuxtLink: RouterLinkStub,
},
})
}
describe('with linkToProfile and popover enabled, on touch screen', () => {
let wrapper
beforeEach(() => {
wrapper = Wrapper({ withLinkToProfile: true, onTouchScreen: true, withPopoverEnabled: true })
})
it('renders button', () => {
expect(wrapper.container).toMatchSnapshot()
})
})
describe('without linkToProfile', () => {
let wrapper
beforeEach(() => {
wrapper = Wrapper({ withLinkToProfile: false, onTouchScreen: false })
})
it('renders span', () => {
expect(wrapper.container).toMatchSnapshot()
})
})
describe('with linkToProfile, on desktop', () => {
let wrapper
beforeEach(() => {
wrapper = Wrapper({ withLinkToProfile: true, onTouchScreen: false })
})
it('renders link', () => {
expect(wrapper.container).toMatchSnapshot()
})
})
})

View File

@ -0,0 +1,46 @@
<template>
<button v-if="showPopover && isTouchDevice" @click.prevent="openMenu">
<slot />
</button>
<span
v-else-if="!linkToProfile || !userLink"
@mouseover="() => showPopover && openMenu()"
@mouseleave="closeMenu"
>
<slot />
</span>
<nuxt-link
v-else
:to="userLink"
@mouseover.native="() => showPopover && openMenu()"
@mouseleave.native="closeMenu"
>
<slot />
</nuxt-link>
</template>
<script>
import { isTouchDevice } from '../utils/isTouchDevice'
export default {
name: 'UserTeaserHelper',
props: {
userLink: { type: Object, default: null },
linkToProfile: { type: Boolean, default: true },
showPopover: { type: Boolean, default: false },
},
computed: {
isTouchDevice() {
return isTouchDevice()
},
},
methods: {
openMenu() {
this.$emit('open-menu')
},
closeMenu() {
this.$emit('close-menu')
},
},
}
</script>

View File

@ -0,0 +1,123 @@
<template>
<dropdown class="user-teaser">
<template #default="{ openMenu, closeMenu }">
<user-teaser-helper
v-if="showAvatar"
:link-to-profile="linkToProfile"
:show-popover="showPopover"
:user-link="userLink"
@open-menu="openMenu(false)"
@close-menu="closeMenu(false)"
data-test="avatarUserLink"
>
<profile-avatar :profile="user" size="small" />
</user-teaser-helper>
<div class="info flex-direction-column">
<div :class="wide ? 'flex-direction-row' : 'flex-direction-column'">
<user-teaser-helper
:link-to-profile="linkToProfile"
:show-popover="showPopover"
:user-link="userLink"
@open-menu="openMenu(false)"
@close-menu="closeMenu(false)"
>
<span class="slug">{{ userSlug }}</span>
<span class="name">{{ userName }}</span>
</user-teaser-helper>
<span v-if="wide">&nbsp;</span>
<span v-if="group">
<span class="text">
{{ $t('group.in') }}
</span>
<nuxt-link :to="groupLink">
<span class="text">
<span class="slug">{{ groupSlug }}</span>
<span v-if="!userOnly" class="name">{{ groupName }}</span>
</span>
</nuxt-link>
</span>
</div>
<span v-if="!userOnly && dateTime" class="text">
<base-icon name="clock" />
<date-time :date-time="dateTime" />
<slot name="dateTime"></slot>
</span>
</div>
</template>
<template #popover="{ isOpen }" v-if="showPopover">
<user-teaser-popover
v-if="isOpen"
:user-id="user.id"
:user-link="linkToProfile ? userLink : null"
/>
</template>
</dropdown>
</template>
<script>
import { mapGetters } from 'vuex'
import DateTime from '~/components/DateTime'
import Dropdown from '~/components/Dropdown'
import ProfileAvatar from '~/components/_new/generic/ProfileAvatar/ProfileAvatar'
import UserTeaserPopover from './UserTeaserPopover'
import UserTeaserHelper from './UserTeaserHelper.vue'
export default {
name: 'UserTeaser',
components: {
ProfileAvatar,
UserTeaserPopover,
UserTeaserHelper,
Dropdown,
DateTime,
},
props: {
linkToProfile: { type: Boolean, default: true },
user: { type: Object, default: null },
group: { type: Object, default: null },
wide: { type: Boolean, default: false },
showAvatar: { type: Boolean, default: true },
dateTime: { type: [Date, String], default: null },
showPopover: { type: Boolean, default: true },
},
computed: {
...mapGetters({
isModerator: 'auth/isModerator',
}),
itsMe() {
return this.user.slug === this.$store.getters['auth/user'].slug
},
userLink() {
const { id, slug } = this.user
if (!(id && slug)) return null
return { name: 'profile-id-slug', params: { slug, id } }
},
userSlug() {
const { slug } = this.user || {}
return slug && `@${slug}`
},
userName() {
const { name } = this.user || {}
return name || this.$t('profile.userAnonym')
},
userOnly() {
return !this.dateTime && !this.group
},
groupLink() {
const { id, slug } = this.group
if (!(id && slug)) return ''
return { name: 'groups-id-slug', params: { slug, id } }
},
groupSlug() {
const { slug } = this.group || {}
return slug && `&${slug}`
},
groupName() {
const { name } = this.group || {}
return name || this.$t('profile.userAnonym')
},
},
}
</script>

View File

@ -0,0 +1,99 @@
import { render } from '@testing-library/vue'
import { RouterLinkStub } from '@vue/test-utils'
import UserTeaserPopover from './UserTeaserPopover.vue'
const localVue = global.localVue
const user = {
id: 'id',
name: 'Tilda Swinton',
slug: 'tilda-swinton',
badgeVerification: {
id: 'bv1',
icon: '/icons/verified',
description: 'Verified',
isDefault: false,
},
badgeTrophiesSelected: [
{
id: 'trophy1',
icon: '/icons/trophy1',
description: 'Trophy 1',
isDefault: false,
},
{
id: 'trophy2',
icon: '/icons/trophy2',
description: 'Trophy 2',
isDefault: false,
},
{
id: 'empty',
icon: '/icons/empty',
description: 'Empty',
isDefault: true,
},
],
}
const userLink = {
name: 'profile-id-slug',
params: { slug: 'slug', id: 'id' },
}
describe('UserTeaserPopover', () => {
const Wrapper = ({ badgesEnabled = true, withUserLink = true, onTouchScreen = false }) => {
const mockIsTouchDevice = onTouchScreen
jest.mock('../utils/isTouchDevice', () => ({
isTouchDevice: jest.fn(() => mockIsTouchDevice),
}))
return render(UserTeaserPopover, {
localVue,
propsData: {
userId: 'id',
userLink: withUserLink ? userLink : null,
},
data: () => ({
User: [user],
}),
stubs: {
NuxtLink: RouterLinkStub,
},
mocks: {
$t: jest.fn((t) => t),
$env: {
BADGES_ENABLED: badgesEnabled,
},
},
})
}
describe('given a touch device', () => {
it('shows button when userLink is provided', () => {
const wrapper = Wrapper({ withUserLink: true, onTouchScreen: true })
expect(wrapper.container).toMatchSnapshot()
})
it('does not show button when userLink is not provided', () => {
const wrapper = Wrapper({ withUserLink: false, onTouchScreen: true })
expect(wrapper.container).toMatchSnapshot()
})
})
describe('given a non-touch device', () => {
it('does not show button when userLink is provided', () => {
const wrapper = Wrapper({ withUserLink: true, onTouchScreen: false })
expect(wrapper.container).toMatchSnapshot()
})
})
it('shows badges when enabled', () => {
const wrapper = Wrapper({ badgesEnabled: true })
expect(wrapper.container).toMatchSnapshot()
})
it('does not show badges when disabled', () => {
const wrapper = Wrapper({ badgesEnabled: false })
expect(wrapper.container).toMatchSnapshot()
})
})

View File

@ -0,0 +1,103 @@
<template>
<div class="placeholder" v-if="!user" />
<div class="user-teaser-popover" v-else>
<badges
v-if="$env.BADGES_ENABLED && user.badgeVerification"
:badges="[user.badgeVerification, ...user.badgeTrophiesSelected]"
/>
<location-info v-if="user.location" :location-data="user.location" class="location-info" />
<ul class="statistics">
<li>
<ds-number :count="user.followedByCount" :label="$t('profile.followers')" />
</li>
<li>
<ds-number
:count="user.contributionsCount"
:label="$t('common.post', null, user.contributionsCount)"
/>
</li>
<li>
<ds-number
:count="user.commentedCount"
:label="$t('common.comment', null, user.commentedCount)"
/>
</li>
</ul>
<nuxt-link v-if="isTouchDevice && userLink" :to="userLink" class="link">
<ds-button primary>{{ $t('user-teaser.popover.open-profile') }}</ds-button>
</nuxt-link>
</div>
</template>
<script>
import Badges from '~/components/Badges.vue'
import LocationInfo from '~/components/UserTeaser/LocationInfo.vue'
import { isTouchDevice } from '~/components/utils/isTouchDevice'
import { userTeaserQuery } from '~/graphql/User.js'
export default {
name: 'UserTeaserPopover',
components: {
Badges,
LocationInfo,
},
props: {
userId: { type: String },
userLink: { type: Object },
},
computed: {
isTouchDevice() {
return isTouchDevice()
},
user() {
return (this.User && this.User[0]) ?? null
},
},
apollo: {
User: {
query() {
return userTeaserQuery(this.$i18n)
},
variables() {
return { id: this.userId }
},
},
},
}
</script>
<style scoped>
.placeholder {
height: 200px;
width: 200px;
}
.user-teaser-popover {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px;
gap: 16px;
min-width: 200px;
}
@media (max-height: 800px) {
.user-teaser-popover {
padding: 0;
gap: 0;
}
}
.location-info {
margin-bottom: 12px;
}
.link {
margin-top: 16px;
}
.statistics {
display: flex;
justify-content: space-between;
width: 100%;
}
</style>

View File

@ -0,0 +1,51 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`LocationInfo renders with distance 1`] = `
<div>
<div
class="location-info"
>
<div
class="location"
>
<span
class="base-icon"
>
<!---->
</span>
Paris
</div>
<div
class="distance"
>
location.distance
</div>
</div>
</div>
`;
exports[`LocationInfo renders without distance 1`] = `
<div>
<div
class="location-info"
>
<div
class="location"
>
<span
class="base-icon"
>
<!---->
</span>
Paris
</div>
<!---->
</div>
</div>
`;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UserTeaserHelper with linkToProfile and popover enabled, on touch screen renders button 1`] = `
<div>
<button />
</div>
`;
exports[`UserTeaserHelper with linkToProfile, on desktop renders link 1`] = `
<div>
<a />
</div>
`;
exports[`UserTeaserHelper without linkToProfile renders span 1`] = `
<div>
<span />
</div>
`;

View File

@ -0,0 +1,547 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UserTeaserPopover does not show badges when disabled 1`] = `
<div>
<div
class="user-teaser-popover"
>
<!---->
<!---->
<ul
class="statistics"
>
<li>
<div
class="ds-number ds-number-size-x-large"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
>
profile.followers
</p>
</div>
</li>
<li>
<div
class="ds-number ds-number-size-x-large"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
>
common.post
</p>
</div>
</li>
<li>
<div
class="ds-number ds-number-size-x-large"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
>
common.comment
</p>
</div>
</li>
</ul>
<!---->
</div>
</div>
`;
exports[`UserTeaserPopover given a non-touch device does not show button when userLink is provided 1`] = `
<div>
<div
class="user-teaser-popover"
>
<div
class="hc-badges"
>
<div
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/icons/verified"
title="Verified"
/>
</div>
<div
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/icons/trophy1"
title="Trophy 1"
/>
</div>
<div
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/icons/trophy2"
title="Trophy 2"
/>
</div>
<div
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/icons/empty"
title="Empty"
/>
</div>
</div>
<!---->
<ul
class="statistics"
>
<li>
<div
class="ds-number ds-number-size-x-large"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
>
profile.followers
</p>
</div>
</li>
<li>
<div
class="ds-number ds-number-size-x-large"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
>
common.post
</p>
</div>
</li>
<li>
<div
class="ds-number ds-number-size-x-large"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
>
common.comment
</p>
</div>
</li>
</ul>
<!---->
</div>
</div>
`;
exports[`UserTeaserPopover given a touch device does not show button when userLink is not provided 1`] = `
<div>
<div
class="user-teaser-popover"
>
<div
class="hc-badges"
>
<div
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/icons/verified"
title="Verified"
/>
</div>
<div
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/icons/trophy1"
title="Trophy 1"
/>
</div>
<div
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/icons/trophy2"
title="Trophy 2"
/>
</div>
<div
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/icons/empty"
title="Empty"
/>
</div>
</div>
<!---->
<ul
class="statistics"
>
<li>
<div
class="ds-number ds-number-size-x-large"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
>
profile.followers
</p>
</div>
</li>
<li>
<div
class="ds-number ds-number-size-x-large"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
>
common.post
</p>
</div>
</li>
<li>
<div
class="ds-number ds-number-size-x-large"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
>
common.comment
</p>
</div>
</li>
</ul>
<!---->
</div>
</div>
`;
exports[`UserTeaserPopover given a touch device shows button when userLink is provided 1`] = `
<div>
<div
class="user-teaser-popover"
>
<div
class="hc-badges"
>
<div
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/icons/verified"
title="Verified"
/>
</div>
<div
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/icons/trophy1"
title="Trophy 1"
/>
</div>
<div
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/icons/trophy2"
title="Trophy 2"
/>
</div>
<div
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/icons/empty"
title="Empty"
/>
</div>
</div>
<!---->
<ul
class="statistics"
>
<li>
<div
class="ds-number ds-number-size-x-large"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
>
profile.followers
</p>
</div>
</li>
<li>
<div
class="ds-number ds-number-size-x-large"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
>
common.post
</p>
</div>
</li>
<li>
<div
class="ds-number ds-number-size-x-large"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
>
common.comment
</p>
</div>
</li>
</ul>
<!---->
</div>
</div>
`;
exports[`UserTeaserPopover shows badges when enabled 1`] = `
<div>
<div
class="user-teaser-popover"
>
<div
class="hc-badges"
>
<div
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/icons/verified"
title="Verified"
/>
</div>
<div
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/icons/trophy1"
title="Trophy 1"
/>
</div>
<div
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/icons/trophy2"
title="Trophy 2"
/>
</div>
<div
class="hc-badge-container"
>
<img
class="hc-badge"
src="/api/icons/empty"
title="Empty"
/>
</div>
</div>
<!---->
<ul
class="statistics"
>
<li>
<div
class="ds-number ds-number-size-x-large"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
>
profile.followers
</p>
</div>
</li>
<li>
<div
class="ds-number ds-number-size-x-large"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
>
common.post
</p>
</div>
</li>
<li>
<div
class="ds-number ds-number-size-x-large"
>
<p
class="ds-text ds-number-count ds-text-size-x-large"
style="margin-bottom: 0px;"
>
0
</p>
<p
class="ds-text ds-number-label ds-text-size-small ds-text-soft"
>
common.comment
</p>
</div>
</li>
</ul>
<!---->
</div>
</div>
`;

View File

@ -0,0 +1,2 @@
export const isTouchDevice = () =>
'ontouchstart' in window || navigator.MaxTouchPoints > 0 || navigator.msMaxTouchPoints > 0

View File

@ -17,9 +17,11 @@ export const locationFragment = (lang) => gql`
fragment location on User {
locationName
location {
id
name: name${lang}
lng
lat
distanceToMe
}
}
`
@ -50,6 +52,19 @@ export const userCountsFragment = gql`
}
`
export const userTeaserFragment = (lang) => gql`
${badgesFragment}
${locationFragment(lang)}
fragment userTeaser on User {
followedByCount
contributionsCount
commentedCount
...badges
...location
}
`
export const postFragment = gql`
fragment post on Post {
id

View File

@ -7,6 +7,7 @@ import {
postFragment,
commentFragment,
groupFragment,
userTeaserFragment,
} from './Fragments'
export const profileUserQuery = (i18n) => {
@ -125,7 +126,7 @@ export const mapUserQuery = (i18n) => {
`
}
export const notificationQuery = (_i18n) => {
export const notificationQuery = () => {
return gql`
${userFragment}
${commentFragment}
@ -483,6 +484,18 @@ export const userDataQuery = (i18n) => {
`
}
export const userTeaserQuery = (i18n) => {
const lang = i18n.locale().toUpperCase()
return gql`
${userTeaserFragment(lang)}
query ($id: ID!) {
User(id: $id) {
...userTeaser
}
}
`
}
export const setTrophyBadgeSelected = gql`
mutation ($slot: Int!, $badgeId: ID) {
setTrophyBadgeSelected(slot: $slot, badgeId: $badgeId) {

View File

@ -636,6 +636,9 @@
"localeSwitch": {
"tooltip": "Sprache wählen"
},
"location": {
"distance": "{distance} km von mir entfernt"
},
"login": {
"email": "Deine E-Mail",
"failure": "Fehlerhafte E-Mail-Adresse oder Passwort.",
@ -1173,5 +1176,10 @@
"newTermsAndConditions": "Neue Nutzungsbedingungen",
"termsAndConditionsNewConfirm": "Ich habe die neuen Nutzungsbedingungen durchgelesen und stimme zu.",
"termsAndConditionsNewConfirmText": "Bitte lies Dir die neuen Nutzungsbedingungen jetzt durch!"
},
"user-teaser": {
"popover": {
"open-profile": "Profil öffnen"
}
}
}

View File

@ -636,6 +636,9 @@
"localeSwitch": {
"tooltip": "Choose language"
},
"location": {
"distance": "{distance} km away from me"
},
"login": {
"email": "Your E-mail",
"failure": "Incorrect email address or password.",
@ -1173,5 +1176,10 @@
"newTermsAndConditions": "New Terms and Conditions",
"termsAndConditionsNewConfirm": "I have read and agree to the new terms of conditions.",
"termsAndConditionsNewConfirmText": "Please read the new terms of use now!"
},
"user-teaser": {
"popover": {
"open-profile": "Open profile"
}
}
}

View File

@ -636,6 +636,9 @@
"localeSwitch": {
"tooltip": null
},
"location": {
"distance": null
},
"login": {
"email": "Su correo electrónico",
"failure": "Dirección de correo electrónico o contraseña incorrecta.",
@ -1173,5 +1176,10 @@
"newTermsAndConditions": "Nuevos términos de uso",
"termsAndConditionsNewConfirm": "He leído y acepto los nuevos términos de uso.",
"termsAndConditionsNewConfirmText": "¡Por favor, lea los nuevos términos de uso ahora!"
},
"user-teaser": {
"popover": {
"open-profile": null
}
}
}

View File

@ -636,6 +636,9 @@
"localeSwitch": {
"tooltip": null
},
"location": {
"distance": null
},
"login": {
"email": "Votre mail",
"failure": "Adresse mail ou mot de passe incorrect.",
@ -1173,5 +1176,10 @@
"newTermsAndConditions": "Nouvelles conditions générales",
"termsAndConditionsNewConfirm": "J'ai lu et accepté les nouvelles conditions générales.",
"termsAndConditionsNewConfirmText": "Veuillez lire les nouvelles conditions d'utilisation dès maintenant !"
},
"user-teaser": {
"popover": {
"open-profile": null
}
}
}

View File

@ -636,6 +636,9 @@
"localeSwitch": {
"tooltip": null
},
"location": {
"distance": null
},
"login": {
"email": "La tua email",
"failure": null,
@ -1173,5 +1176,10 @@
"newTermsAndConditions": "Nuovi Termini e Condizioni",
"termsAndConditionsNewConfirm": "Ho letto e accetto le nuove condizioni generali di contratto.",
"termsAndConditionsNewConfirmText": "Si prega di leggere le nuove condizioni d'uso ora!"
},
"user-teaser": {
"popover": {
"open-profile": null
}
}
}

View File

@ -636,6 +636,9 @@
"localeSwitch": {
"tooltip": null
},
"location": {
"distance": null
},
"login": {
"email": "Uw E-mail",
"failure": null,
@ -1173,5 +1176,10 @@
"newTermsAndConditions": null,
"termsAndConditionsNewConfirm": null,
"termsAndConditionsNewConfirmText": null
},
"user-teaser": {
"popover": {
"open-profile": null
}
}
}

View File

@ -636,6 +636,9 @@
"localeSwitch": {
"tooltip": null
},
"location": {
"distance": null
},
"login": {
"email": "Twój adres e-mail",
"failure": null,
@ -1173,5 +1176,10 @@
"newTermsAndConditions": null,
"termsAndConditionsNewConfirm": null,
"termsAndConditionsNewConfirmText": null
},
"user-teaser": {
"popover": {
"open-profile": null
}
}
}

View File

@ -636,6 +636,9 @@
"localeSwitch": {
"tooltip": null
},
"location": {
"distance": null
},
"login": {
"email": "Seu email",
"failure": "Endereço de e-mail ou senha incorretos.",
@ -1173,5 +1176,10 @@
"newTermsAndConditions": "Novos Termos e Condições",
"termsAndConditionsNewConfirm": "Eu li e concordo com os novos termos de condições.",
"termsAndConditionsNewConfirmText": "Por favor, leia os novos termos de uso agora!"
},
"user-teaser": {
"popover": {
"open-profile": null
}
}
}

View File

@ -636,6 +636,9 @@
"localeSwitch": {
"tooltip": null
},
"location": {
"distance": null
},
"login": {
"email": "Электронная почта",
"failure": "Неверный адрес электронной почты или пароль.",
@ -1173,5 +1176,10 @@
"newTermsAndConditions": "Новые условия и положения",
"termsAndConditionsNewConfirm": "Я прочитал(а) и согласен(на) с новыми условиями.",
"termsAndConditionsNewConfirmText": "Пожалуйста, ознакомьтесь с новыми условиями использования!"
},
"user-teaser": {
"popover": {
"open-profile": null
}
}
}

File diff suppressed because it is too large Load Diff