mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2025-12-12 23:35:58 +00:00
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:
parent
65f764f6d9
commit
33274e5b9a
@ -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')
|
||||
})
|
||||
|
||||
@ -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;
|
||||
@ -359,37 +359,37 @@ $media-query-medium: (min-width: 768px);
|
||||
$media-query-large: (min-width: 1024px);
|
||||
$media-query-x-large: (min-width: 1200px);
|
||||
|
||||
/**
|
||||
/**
|
||||
* @tokens Background Images
|
||||
*/
|
||||
|
||||
/**
|
||||
/**
|
||||
* @tokens Header Color
|
||||
*/
|
||||
|
||||
$color-header-background: $color-neutral-100;
|
||||
|
||||
/**
|
||||
/**
|
||||
* @tokens Footer Color
|
||||
*/
|
||||
|
||||
$color-footer-background: $color-neutral-100;
|
||||
$color-footer-link: $color-primary;
|
||||
|
||||
/**
|
||||
/**
|
||||
* @tokens Locale Menu Color
|
||||
*/
|
||||
|
||||
$color-locale-menu: $text-color-soft;
|
||||
|
||||
/**
|
||||
/**
|
||||
* @tokens Donation Bar Color
|
||||
*/
|
||||
|
||||
$color-donation-bar: $color-primary;
|
||||
$color-donation-bar-light: $color-primary-light;
|
||||
|
||||
/**
|
||||
/**
|
||||
* @tokens Toast Color
|
||||
*/
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -179,4 +177,4 @@ hr {
|
||||
|
||||
.dropdown-arrow {
|
||||
font-size: $font-size-xx-small;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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')
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -127,7 +127,7 @@ export default {
|
||||
apollo: {
|
||||
notifications: {
|
||||
query() {
|
||||
return notificationQuery(this.$i18n)
|
||||
return notificationQuery()
|
||||
},
|
||||
variables() {
|
||||
return {
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
|
||||
31
webapp/components/UserTeaser/LocationInfo.spec.js
Normal file
31
webapp/components/UserTeaser/LocationInfo.spec.js
Normal 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()
|
||||
})
|
||||
})
|
||||
44
webapp/components/UserTeaser/LocationInfo.vue
Normal file
44
webapp/components/UserTeaser/LocationInfo.vue
Normal 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>
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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"> </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 {
|
||||
|
||||
71
webapp/components/UserTeaser/UserTeaserHelper.spec.js
Normal file
71
webapp/components/UserTeaser/UserTeaserHelper.spec.js
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
46
webapp/components/UserTeaser/UserTeaserHelper.vue
Normal file
46
webapp/components/UserTeaser/UserTeaserHelper.vue
Normal 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>
|
||||
123
webapp/components/UserTeaser/UserTeaserNonAnonymous.vue
Normal file
123
webapp/components/UserTeaser/UserTeaserNonAnonymous.vue
Normal 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"> </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>
|
||||
99
webapp/components/UserTeaser/UserTeaserPopover.spec.js
Normal file
99
webapp/components/UserTeaser/UserTeaserPopover.spec.js
Normal 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()
|
||||
})
|
||||
})
|
||||
103
webapp/components/UserTeaser/UserTeaserPopover.vue
Normal file
103
webapp/components/UserTeaser/UserTeaserPopover.vue
Normal 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>
|
||||
@ -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>
|
||||
`;
|
||||
1136
webapp/components/UserTeaser/__snapshots__/UserTeaser.spec.js.snap
Normal file
1136
webapp/components/UserTeaser/__snapshots__/UserTeaser.spec.js.snap
Normal file
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
`;
|
||||
@ -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>
|
||||
`;
|
||||
2
webapp/components/utils/isTouchDevice.js
Normal file
2
webapp/components/utils/isTouchDevice.js
Normal file
@ -0,0 +1,2 @@
|
||||
export const isTouchDevice = () =>
|
||||
'ontouchstart' in window || navigator.MaxTouchPoints > 0 || navigator.msMaxTouchPoints > 0
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user