fix(webapp): notifications - UI Improvements (#8559)

* Notifications view:
- restructured broken layout
- joined several columns for mobile view
- moved button from footer to header
- set alternating colors for the table rows

UserTeaser
- added injectedText
- added injectedDate
- fixed padding

* fixed race-condition with default behavior of browser

* - fixed: jumping menu / menu should get closed by click on notification
- fixed: NotificationList replaced by NotificationTable

* - fixed: menu gets closed when cursor leaves content area, but it is still within popup

* - fixed: menu top buttons should be next to each other

* - fixed: popup background overlay remains after NotificationMenu disappeared after viewport change to mobile

* - fixed lint errors

* - fixed tests + snapshots

* - fixed e2e test

* fix lint error

Co-authored-by: Sebastian Stein <sebastian@codepassion.de>

* Fix locale identifier to have single quotes 'notifications.reason.on_date'

---------

Co-authored-by: Sebastian Stein <sebastian@codepassion.de>
Co-authored-by: Wolfgang Huß <wolle.huss@pjannto.com>
Co-authored-by: Ulf Gebhardt <ulf.gebhardt@webcraft-media.de>
This commit is contained in:
sebastian2357 2025-05-25 18:44:33 +02:00 committed by GitHub
parent 18ae2a04ab
commit a69006873d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 436 additions and 574 deletions

View File

@ -4,7 +4,7 @@ defineStep('open the notification menu and click on the first item', () => {
cy.get('.notifications-menu') cy.get('.notifications-menu')
.invoke('show') .invoke('show')
.click() // 'invoke('show')' because of the delay for show the menu .click() // 'invoke('show')' because of the delay for show the menu
cy.get('.notification .link') cy.get('.notification-content a')
.first() .first()
.click({force: true}) .click({force: true})
}) })

View File

@ -1,200 +0,0 @@
import { mount, RouterLinkStub } from '@vue/test-utils'
import Notification from './Notification.vue'
import Vuex from 'vuex'
const localVue = global.localVue
describe('Notification', () => {
let stubs
let getters
let mocks
let propsData
let wrapper
beforeEach(() => {
propsData = {}
mocks = {
$t: (key) => key,
}
stubs = {
NuxtLink: RouterLinkStub,
'client-only': true,
}
getters = {
'auth/user': () => {
return {}
},
'auth/isModerator': () => false,
}
})
const Wrapper = () => {
const store = new Vuex.Store({
getters,
})
return mount(Notification, {
stubs,
store,
mocks,
propsData,
localVue,
})
}
describe('given a notification about a comment on a post', () => {
beforeEach(() => {
propsData.notification = {
reason: 'commented_on_post',
from: {
__typename: 'Comment',
id: 'comment-1',
contentExcerpt:
'<a href="/profile/u123" target="_blank">@dagobert-duck</a> is the best on this comment.',
post: {
title: "It's a post title",
id: 'post-1',
slug: 'its-a-title',
contentExcerpt: 'Post content.',
},
},
}
})
it('renders reason', () => {
wrapper = Wrapper()
expect(wrapper.find('.notification > .description').text()).toEqual(
'notifications.reason.commented_on_post',
)
})
it('renders title', () => {
wrapper = Wrapper()
expect(wrapper.text()).toContain("It's a post title")
})
it('renders the identifier "notifications.comment"', () => {
wrapper = Wrapper()
expect(wrapper.text()).toContain('notifications.comment')
})
it('renders the contentExcerpt', () => {
wrapper = Wrapper()
expect(wrapper.text()).toContain('@dagobert-duck is the best on this comment.')
})
it('has no class "--read"', () => {
wrapper = Wrapper()
expect(wrapper.classes()).not.toContain('--read')
})
describe('that is read', () => {
beforeEach(() => {
propsData.notification.read = true
wrapper = Wrapper()
})
it('has class "--read"', () => {
expect(wrapper.classes()).toContain('--read')
})
})
})
describe('given a notification about a mention in a post', () => {
beforeEach(() => {
propsData.notification = {
reason: 'mentioned_in_post',
from: {
__typename: 'Post',
title: "It's a post title",
id: 'post-1',
slug: 'its-a-title',
contentExcerpt:
'<a href="/profile/u3" target="_blank">@jenny-rostock</a> is the best on this post.',
},
}
})
it('renders reason', () => {
wrapper = Wrapper()
expect(wrapper.find('.notification > .description').text()).toEqual(
'notifications.reason.mentioned_in_post',
)
})
it('renders title', () => {
wrapper = Wrapper()
expect(wrapper.text()).toContain("It's a post title")
})
it('renders the contentExcerpt', () => {
wrapper = Wrapper()
expect(wrapper.text()).toContain('@jenny-rostock is the best on this post.')
})
it('has no class "--read"', () => {
wrapper = Wrapper()
expect(wrapper.classes()).not.toContain('--read')
})
describe('that is read', () => {
beforeEach(() => {
propsData.notification.read = true
wrapper = Wrapper()
})
it('has class "--read"', () => {
expect(wrapper.classes()).toContain('--read')
})
})
})
describe('given a notification about a mention in a comment', () => {
beforeEach(() => {
propsData.notification = {
reason: 'mentioned_in_comment',
from: {
__typename: 'Comment',
id: 'comment-1',
contentExcerpt:
'<a href="/profile/u123" target="_blank">@dagobert-duck</a> is the best on this comment.',
post: {
title: "It's a post title",
id: 'post-1',
slug: 'its-a-title',
contentExcerpt: 'Post content.',
},
},
}
})
it('renders reason', () => {
wrapper = Wrapper()
expect(wrapper.find('.notification > .description').text()).toEqual(
'notifications.reason.mentioned_in_comment',
)
})
it('renders title', () => {
wrapper = Wrapper()
expect(wrapper.text()).toContain("It's a post title")
})
it('renders the identifier "notifications.comment"', () => {
wrapper = Wrapper()
expect(wrapper.text()).toContain('notifications.comment')
})
it('renders the contentExcerpt', () => {
wrapper = Wrapper()
expect(wrapper.text()).toContain('@dagobert-duck is the best on this comment.')
})
it('has no class "--read"', () => {
wrapper = Wrapper()
expect(wrapper.classes()).not.toContain('--read')
})
describe('that is read', () => {
beforeEach(() => {
propsData.notification.read = true
wrapper = Wrapper()
})
it('has class "--read"', () => {
expect(wrapper.classes()).toContain('--read')
})
})
})
})

View File

@ -1,100 +0,0 @@
<template>
<article :class="{ '--read': notification.read, notification: true }">
<client-only>
<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>
<nuxt-link
class="link"
:to="{ name: isGroup ? 'groups-id-slug' : 'post-id-slug', params, ...hashParam }"
@click.native="$emit('read')"
>
<base-card wideContent>
<h2 class="title">{{ from.title || from.groupName || from.post.title }}</h2>
<p>
<strong v-if="isComment" class="comment">{{ $t(`notifications.comment`) }}:</strong>
{{ from.contentExcerpt | removeHtml }}
<strong v-if="isGroup" class="comment">{{ $t(`notifications.group`) }}:</strong>
{{ from.descriptionExcerpt | removeHtml }}
</p>
</base-card>
</nuxt-link>
</article>
</template>
<script>
import UserTeaser from '~/components/UserTeaser/UserTeaser'
export default {
name: 'Notification',
components: {
UserTeaser,
},
props: {
notification: {
type: Object,
required: true,
},
},
computed: {
from() {
return this.notification.from
},
isComment() {
return this.from.__typename === 'Comment'
},
isGroup() {
return this.from.__typename === 'Group'
},
params() {
const target = this.isComment ? this.from.post : this.from
return {
id: target.id,
slug: target.slug,
}
},
hashParam() {
return this.isComment ? { hash: `#commentId-${this.from.id}` } : {}
},
},
}
</script>
<style lang="scss">
.notification {
margin-bottom: $space-base;
&:first-of-type {
margin-top: $space-x-small;
}
&.--read {
opacity: $opacity-disabled;
}
> .description {
margin-bottom: $space-x-small;
}
> .link {
display: block;
color: $text-color-base;
&:hover {
color: $color-primary;
}
}
.user-teaser {
margin-bottom: $space-x-small;
}
.comment {
font-weight: $font-weight-bold;
}
}
</style>

View File

@ -1,103 +0,0 @@
import { shallowMount, mount, RouterLinkStub } from '@vue/test-utils'
import NotificationList from './NotificationList'
import Notification from '../Notification/Notification'
import Vuex from 'vuex'
import { notifications } from '~/components/utils/Notifications'
const localVue = global.localVue
localVue.filter('truncate', (string) => string)
describe('NotificationList.vue', () => {
let wrapper
let mocks
let stubs
let store
let propsData
beforeEach(() => {
store = new Vuex.Store({
getters: {
'auth/isModerator': () => false,
'auth/user': () => {
return {}
},
},
})
mocks = {
$t: jest.fn(),
}
stubs = {
NuxtLink: RouterLinkStub,
'client-only': true,
'v-popover': true,
}
propsData = { notifications }
})
describe('shallowMount', () => {
const Wrapper = () => {
return shallowMount(NotificationList, {
propsData,
mocks,
store,
localVue,
stubs,
})
}
beforeEach(() => {
wrapper = Wrapper()
})
it('renders Notification.vue for each notification of the user', () => {
expect(wrapper.findAllComponents(Notification)).toHaveLength(2)
})
})
describe('mount', () => {
const Wrapper = () => {
return mount(NotificationList, {
propsData,
mocks,
stubs,
store,
localVue,
})
}
beforeEach(() => {
wrapper = Wrapper()
})
describe('click on a notification', () => {
beforeEach(() => {
wrapper.find('.notification > .link').trigger('click')
})
it("emits 'markAsRead' with the id of the notification source", () => {
expect(wrapper.emitted('markAsRead')[0]).toEqual(['post-1'])
})
})
})
describe('shallowMount with no notifications', () => {
const Wrapper = () => {
return shallowMount(NotificationList, {
propsData: {},
mocks,
store,
localVue,
})
}
beforeEach(() => {
wrapper = Wrapper()
})
it('renders Notification.vue zero times', () => {
expect(wrapper.findAllComponents(Notification)).toHaveLength(0)
})
})
})

View File

@ -1,32 +0,0 @@
<template>
<div>
<notification
v-for="notification in notifications"
:key="notification.id"
:notification="notification"
@read="markAsRead(notification.from.id)"
/>
</div>
</template>
<script>
import Notification from '../Notification/Notification'
export default {
name: 'NotificationList',
components: {
Notification,
},
props: {
notifications: {
type: Array,
default: () => [],
},
},
methods: {
markAsRead(notificationSourceId) {
this.$emit('markAsRead', notificationSourceId)
},
},
}
</script>

View File

@ -26,7 +26,14 @@
<counter-icon icon="bell" :count="unreadNotificationsCount" danger /> <counter-icon icon="bell" :count="unreadNotificationsCount" danger />
</base-button> </base-button>
</nuxt-link> </nuxt-link>
<dropdown v-else class="notifications-menu" offset="8" :placement="placement"> <dropdown
v-else
class="notifications-menu"
offset="8"
:placement="placement"
noMouseLeaveClosing
ref="dropdown"
>
<template #default="{ toggleMenu }"> <template #default="{ toggleMenu }">
<base-button <base-button
ghost ghost
@ -42,14 +49,12 @@
</template> </template>
<template #popover="{ closeMenu }"> <template #popover="{ closeMenu }">
<ds-flex class="notifications-link-container"> <ds-flex class="notifications-link-container">
<ds-flex-item class="notifications-link-container-item" :width="{ base: '100%' }" centered> <ds-flex-item>
<nuxt-link :to="{ name: 'notifications' }"> <nuxt-link :to="{ name: 'notifications' }">
<base-button ghost primary> <base-button ghost primary>
{{ $t('notifications.pageLink') }} {{ $t('notifications.pageLink') }}
</base-button> </base-button>
</nuxt-link> </nuxt-link>
</ds-flex-item>
<ds-flex-item class="notifications-link-container-item" :width="{ base: '100%' }" centered>
<base-button <base-button
ghost ghost
primary primary
@ -61,7 +66,11 @@
</ds-flex-item> </ds-flex-item>
</ds-flex> </ds-flex>
<div class="notifications-menu-popover"> <div class="notifications-menu-popover">
<notification-list :notifications="notifications" @markAsRead="markAsRead" /> <notifications-table
@markNotificationAsRead="markAsReadAndCloseMenu($event, closeMenu)"
:notifications="notifications"
:show-popover="false"
/>
</div> </div>
</template> </template>
</dropdown> </dropdown>
@ -78,14 +87,14 @@ import {
} from '~/graphql/User' } from '~/graphql/User'
import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon' import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon'
import Dropdown from '~/components/Dropdown' import Dropdown from '~/components/Dropdown'
import NotificationList from '../NotificationList/NotificationList' import NotificationsTable from '../NotificationsTable/NotificationsTable.vue'
export default { export default {
name: 'NotificationMenu', name: 'NotificationMenu',
components: { components: {
NotificationsTable,
CounterIcon, CounterIcon,
Dropdown, Dropdown,
NotificationList,
}, },
data() { data() {
return { return {
@ -96,14 +105,25 @@ export default {
placement: { type: String }, placement: { type: String },
noMenu: { type: Boolean, default: false }, noMenu: { type: Boolean, default: false },
}, },
mounted() {
window.addEventListener('resize', this.handleResize)
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize)
},
methods: { methods: {
async markAsRead(notificationSourceId) { handleResize() {
const variables = { id: notificationSourceId } // When the viewport get resized close menu
this.$refs?.dropdown?.closeMenu?.()
},
async markAsReadAndCloseMenu(notificationSourceId, closeMenu) {
try { try {
await this.$apollo.mutate({ await this.$apollo.mutate({
mutation: markAsReadMutation(this.$i18n), mutation: markAsReadMutation(this.$i18n),
variables, variables: { id: notificationSourceId },
}) })
closeMenu?.()
} catch (error) { } catch (error) {
this.$toast.error(error.message) this.$toast.error(error.message)
} }
@ -113,7 +133,7 @@ export default {
return return
} }
closeMenu() closeMenu?.()
try { try {
await this.$apollo.mutate({ await this.$apollo.mutate({
mutation: markAllAsReadMutation(this.$i18n), mutation: markAllAsReadMutation(this.$i18n),
@ -178,12 +198,8 @@ export default {
} }
.notifications-link-container { .notifications-link-container {
background-color: $background-color-softer-active; background-color: $background-color-softer-active;
justify-content: center; text-align: right;
padding: $space-x-small; padding: $space-x-small 0;
flex-direction: row; flex-direction: row;
} }
.notifications-link-container-item {
justify-content: center;
display: flex;
}
</style> </style>

View File

@ -63,11 +63,7 @@ describe('NotificationsTable.vue', () => {
expect(wrapper.find('.notification-grid').exists()).toBe(true) expect(wrapper.find('.notification-grid').exists()).toBe(true)
}) })
describe('renders 4 columns', () => { describe('renders 2 columns', () => {
it('for icon', () => {
expect(wrapper.vm.fields.icon).toBeTruthy()
})
it('for user', () => { it('for user', () => {
expect(wrapper.vm.fields.user).toBeTruthy() expect(wrapper.vm.fields.user).toBeTruthy()
}) })
@ -75,10 +71,6 @@ describe('NotificationsTable.vue', () => {
it('for post', () => { it('for post', () => {
expect(wrapper.vm.fields.post).toBeTruthy() expect(wrapper.vm.fields.post).toBeTruthy()
}) })
it('for content', () => {
expect(wrapper.vm.fields.content).toBeTruthy()
})
}) })
describe('Post', () => { describe('Post', () => {
@ -93,9 +85,9 @@ describe('NotificationsTable.vue', () => {
}) })
it('renders the reason for the notification', () => { it('renders the reason for the notification', () => {
const dsTexts = firstRowNotification.findAll('.ds-text') const dsTexts = firstRowNotification.findAll('.info span')
const reason = dsTexts.filter( const reason = dsTexts.filter((element) =>
(element) => element.text() === 'notifications.reason.mentioned_in_post', element.text().startsWith('notifications.reason.mentioned_in_post'),
) )
expect(reason.exists()).toBe(true) expect(reason.exists()).toBe(true)
}) })
@ -106,7 +98,7 @@ describe('NotificationsTable.vue', () => {
}) })
it("renders the Post's content", () => { it("renders the Post's content", () => {
const boldTags = firstRowNotification.findAll('b') const boldTags = firstRowNotification.findAll('p')
const content = boldTags.filter( const content = boldTags.filter(
(element) => element.text() === postNotification.from.contentExcerpt, (element) => element.text() === postNotification.from.contentExcerpt,
) )
@ -126,9 +118,9 @@ describe('NotificationsTable.vue', () => {
}) })
it('renders the reason for the notification', () => { it('renders the reason for the notification', () => {
const dsTexts = secondRowNotification.findAll('.ds-text') const dsTexts = secondRowNotification.findAll('.info span')
const reason = dsTexts.filter( const reason = dsTexts.filter((element) =>
(element) => element.text() === 'notifications.reason.mentioned_in_comment', element.text().startsWith('notifications.reason.mentioned_in_comment'),
) )
expect(reason.exists()).toBe(true) expect(reason.exists()).toBe(true)
}) })
@ -139,7 +131,7 @@ describe('NotificationsTable.vue', () => {
}) })
it("renders the Post's content", () => { it("renders the Post's content", () => {
const boldTags = secondRowNotification.findAll('b') const boldTags = secondRowNotification.findAll('p')
const content = boldTags.filter( const content = boldTags.filter(
(element) => element.text() === commentNotification.from.contentExcerpt, (element) => element.text() === commentNotification.from.contentExcerpt,
) )

View File

@ -17,50 +17,45 @@
<ds-grid> <ds-grid>
<ds-grid-item> <ds-grid-item>
<ds-flex class="user-section"> <ds-flex class="user-section">
<ds-flex-item :width="{ base: '20%' }">
<div>
<base-card :wide-content="true">
<base-icon
v-if="notification.from.post"
name="comment"
v-tooltip="{ content: $t('notifications.comment'), placement: 'right' }"
/>
<base-icon
v-else
name="bookmark"
v-tooltip="{ content: $t('notifications.post'), placement: 'right' }"
/>
</base-card>
</div>
</ds-flex-item>
<ds-flex-item> <ds-flex-item>
<div> <base-card :wide-content="true">
<base-card :wide-content="true"> <client-only>
<ds-space margin-bottom="base"> <user-teaser
<client-only> :user="
<user-teaser isGroup(notification.from)
:user=" ? notification.relatedUser
isGroup(notification.from) : notification.from.author
? notification.relatedUser "
: notification.from.author :class="{ 'notification-status': notification.read }"
" :date-time="notification.from.createdAt"
:date-time="notification.from.createdAt" :injected-text="$t(`notifications.reason.${notification.reason}`)"
:class="{ 'notification-status': notification.read }" :injected-date="true"
/> :show-popover="showPopover"
</client-only> />
</ds-space> </client-only>
<ds-text :class="{ 'notification-status': notification.read, reason: true }"> </base-card>
{{ $t(`notifications.reason.${notification.reason}`) }}
</ds-text>
</base-card>
</div>
</ds-flex-item> </ds-flex-item>
</ds-flex> </ds-flex>
</ds-grid-item> </ds-grid-item>
<ds-grid-item> <ds-grid-item>
<ds-flex class="content-section" :direction="{ base: 'column', xs: 'row' }"> <base-card :wide-content="true">
<ds-flex-item> <div class="notification-container">
<base-card :wide-content="true"> <!-- Icon with responsive sizing -->
<div class="notification-icon">
<base-icon
v-if="notification.from.post"
name="comment"
v-tooltip="{ content: $t('notifications.comment'), placement: 'right' }"
/>
<base-icon
v-else
name="bookmark"
v-tooltip="{ content: $t('notifications.post'), placement: 'right' }"
/>
</div>
<!-- Content section with title and description -->
<div class="notification-content">
<nuxt-link <nuxt-link
class="notification-mention-post" class="notification-mention-post"
:class="{ 'notification-status': notification.read }" :class="{ 'notification-status': notification.read }"
@ -69,7 +64,7 @@
params: params(notification.from), params: params(notification.from),
hash: hashParam(notification.from), hash: hashParam(notification.from),
}" }"
@click.native="markNotificationAsRead(notification.from.id)" @click.native.prevent="handleNotificationClick(notification)"
> >
<b> <b>
{{ {{
@ -79,19 +74,18 @@
}} }}
</b> </b>
</nuxt-link> </nuxt-link>
</base-card> <p
</ds-flex-item> class="notification-description"
<ds-flex-item> :class="{ 'notification-status': notification.read }"
<base-card :wide-content="true"> >
<b :class="{ 'notification-status': notification.read }">
{{ {{
notification.from.contentExcerpt || notification.from.contentExcerpt ||
notification.from.descriptionExcerpt | removeHtml notification.from.descriptionExcerpt | removeHtml
}} }}
</b> </p>
</base-card> </div>
</ds-flex-item> </div>
</ds-flex> </base-card>
</ds-grid-item> </ds-grid-item>
</ds-grid> </ds-grid>
</ds-grid-item> </ds-grid-item>
@ -116,25 +110,18 @@ export default {
mixins: [mobile(maxMobileWidth)], mixins: [mobile(maxMobileWidth)],
props: { props: {
notifications: { type: Array, default: () => [] }, notifications: { type: Array, default: () => [] },
showPopover: { type: Boolean, default: true },
}, },
computed: { computed: {
fields() { fields() {
return { return {
icon: {
label: ' ',
width: '5',
},
user: { user: {
label: this.$t('notifications.user'), label: this.$t('notifications.user'),
width: '45%', width: '50%',
}, },
post: { post: {
label: this.$t('notifications.post'), label: this.$t('notifications.post'),
width: '25%', width: '50%',
},
content: {
label: this.$t('notifications.content'),
width: '25%',
}, },
} }
}, },
@ -159,7 +146,23 @@ export default {
return this.isComment(notificationSource) ? `#commentId-${notificationSource.id}` : '' return this.isComment(notificationSource) ? `#commentId-${notificationSource.id}` : ''
}, },
markNotificationAsRead(notificationSourceId) { markNotificationAsRead(notificationSourceId) {
this.$emit('markNotificationAsRead', notificationSourceId) return new Promise((resolve) => {
this.$emit('markNotificationAsRead', notificationSourceId)
resolve()
})
},
async handleNotificationClick(notification) {
const route = {
name: this.isGroup(notification.from) ? 'groups-id-slug' : 'post-id-slug',
params: this.params(notification.from),
hash: this.hashParam(notification.from),
}
await this.markNotificationAsRead(notification.from.id)
setTimeout(() => {
this.$router.push(route)
}, 10)
}, },
}, },
} }
@ -172,12 +175,6 @@ export default {
.notification-grid .content-section { .notification-grid .content-section {
flex-wrap: nowrap; flex-wrap: nowrap;
} }
.notification-grid .ds-grid.header-grid {
grid-template-columns: 1fr 4fr 3fr 3fr !important;
}
.notification-grid-row {
border-top: 1px dotted #e5e3e8;
}
.notification-grid .base-card { .notification-grid .base-card {
border-radius: 0; border-radius: 0;
box-shadow: none; box-shadow: none;
@ -190,6 +187,19 @@ export default {
grid-template-rows: 1fr; grid-template-rows: 1fr;
gap: 0px !important; gap: 0px !important;
} }
.notification-grid-row {
padding: 10px;
border-bottom: 1px dotted #e5e3e8;
background-color: white;
&:nth-child(odd) {
background-color: $color-neutral-90;
}
.base-card {
padding: 8px 0;
background-color: unset !important;
}
}
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.notification-grid .ds-grid { .notification-grid .ds-grid {
grid-template-columns: 1fr !important; grid-template-columns: 1fr !important;
@ -197,10 +207,47 @@ export default {
.notification-grid .content-section { .notification-grid .content-section {
border-top: 1px dotted #e5e3e8; border-top: 1px dotted #e5e3e8;
} }
.notification-grid-row { }
box-shadow: 0px 12px 26px -4px rgb(0 0 0 / 10%);
margin-top: 5px; .notification-description {
border-top: none; margin-top: 4px;
}
.notification-container {
display: flex;
align-items: flex-start;
gap: 10px;
.notification-icon {
flex-shrink: 0;
} }
} }
/* Desktop icon size */
@media (min-width: 768px) {
.notification-icon {
width: 18px;
}
.notification-icon :deep(svg) {
width: 25px;
height: 25px;
}
}
/* Mobile icon size */
@media (max-width: 767px) {
.notification-icon {
width: 34px;
text-align: center;
}
.notification-icon :deep(svg) {
width: 50px;
height: 50px;
}
}
.notification-content {
flex: 1;
}
</style> </style>

View File

@ -15,6 +15,8 @@
:show-avatar="showAvatar" :show-avatar="showAvatar"
:date-time="dateTime" :date-time="dateTime"
:show-popover="showPopover" :show-popover="showPopover"
:injectedText="injectedText"
:injectedDate="injectedDate"
@close="closeMenu" @close="closeMenu"
/> />
</client-only> </client-only>
@ -41,6 +43,8 @@ export default {
showAvatar: { type: Boolean, default: true }, showAvatar: { type: Boolean, default: true },
dateTime: { type: [Date, String], default: null }, dateTime: { type: [Date, String], default: null },
showPopover: { type: Boolean, default: true }, showPopover: { type: Boolean, default: true },
injectedText: { type: String, default: null },
injectedDate: { type: Boolean, default: false },
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({
@ -76,10 +80,10 @@ export default {
} }
.info { .info {
padding-left: $space-xx-small; padding-left: 10px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: break-spaces;
&.anonymous { &.anonymous {
font-size: $font-size-base; font-size: $font-size-base;

View File

@ -36,8 +36,10 @@
</span> </span>
</nuxt-link> </nuxt-link>
</span> </span>
<!-- eslint-disable-next-line prettier/prettier -->
<span>{{ injectedText }}<span v-if="injectedText && injectedDate && !userOnly && dateTime"> {{$t('notifications.reason.on_date')}} <date-time :date-time="dateTime" /></span></span>
</div> </div>
<span v-if="!userOnly && dateTime" class="text"> <span v-if="!userOnly && !injectedDate && dateTime" class="text">
<base-icon name="clock" /> <base-icon name="clock" />
<date-time :date-time="dateTime" /> <date-time :date-time="dateTime" />
<slot name="dateTime"></slot> <slot name="dateTime"></slot>
@ -81,6 +83,8 @@ export default {
showAvatar: { type: Boolean, default: true }, showAvatar: { type: Boolean, default: true },
dateTime: { type: [Date, String], default: null }, dateTime: { type: [Date, String], default: null },
showPopover: { type: Boolean, default: true }, showPopover: { type: Boolean, default: true },
injectedText: { type: String, default: null },
injectedDate: { type: Boolean, default: false },
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({

View File

@ -51,6 +51,11 @@ exports[`UserTeaser given an user avatar is disabled does not render the avatar
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -199,6 +204,11 @@ exports[`UserTeaser given an user user is disabled current user is a moderator r
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -315,6 +325,11 @@ exports[`UserTeaser given an user with linkToProfile, on desktop renders 1`] = `
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -400,6 +415,11 @@ exports[`UserTeaser given an user with linkToProfile, on desktop when hovering t
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -489,6 +509,11 @@ exports[`UserTeaser given an user with linkToProfile, on touch screen renders 1`
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -574,6 +599,11 @@ exports[`UserTeaser given an user with linkToProfile, on touch screen when click
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -663,6 +693,11 @@ exports[`UserTeaser given an user without linkToProfile, on desktop renders 1`]
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -748,6 +783,11 @@ exports[`UserTeaser given an user without linkToProfile, on desktop when hoverin
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -837,6 +877,11 @@ exports[`UserTeaser given an user without linkToProfile, on desktop when hoverin
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -925,6 +970,11 @@ exports[`UserTeaser given an user without linkToProfile, on touch screen renders
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -1010,6 +1060,11 @@ exports[`UserTeaser given an user without linkToProfile, on touch screen when cl
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -1099,6 +1154,11 @@ exports[`UserTeaser given an user without linkToProfile, on touch screen when cl
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->

View File

@ -804,15 +804,16 @@
"pageLink": "Alle Benachrichtigungen", "pageLink": "Alle Benachrichtigungen",
"post": "Beitrag oder Gruppe", "post": "Beitrag oder Gruppe",
"reason": { "reason": {
"changed_group_member_role": "Hat deine Rolle in der Gruppe geändert …", "changed_group_member_role": "Hat deine Rolle in der Gruppe geändert",
"commented_on_post": "Hat einen Beitrag den du beobachtest kommentiert …", "commented_on_post": "Hat einen Beitrag den du beobachtest kommentiert",
"followed_user_posted": "Hat einen neuen Betrag geschrieben …", "followed_user_posted": "Hat einen neuen Betrag geschrieben",
"mentioned_in_comment": "Hat dich in einem Kommentar erwähnt …", "mentioned_in_comment": "Hat dich in einem Kommentar erwähnt",
"mentioned_in_post": "Hat dich in einem Beitrag erwähnt …", "mentioned_in_post": "Hat dich in einem Beitrag erwähnt",
"post_in_group": "Hat einen Beitrag in der Gruppe geschrieben …", "on_date": "am",
"removed_user_from_group": "Hat dich aus der Gruppe entfernt …", "post_in_group": "Hat einen Beitrag in der Gruppe geschrieben",
"user_joined_group": "Ist deiner Gruppe beigetreten …", "removed_user_from_group": "Hat dich aus der Gruppe entfernt",
"user_left_group": "Hat deine Gruppe verlassen …" "user_joined_group": "Ist deiner Gruppe beigetreten",
"user_left_group": "Hat deine Gruppe verlassen"
}, },
"title": "Benachrichtigungen", "title": "Benachrichtigungen",
"user": "Nutzer" "user": "Nutzer"

View File

@ -804,15 +804,16 @@
"pageLink": "All notifications", "pageLink": "All notifications",
"post": "Post or Group", "post": "Post or Group",
"reason": { "reason": {
"changed_group_member_role": "Changed your role in group …", "changed_group_member_role": "Changed your role in group",
"commented_on_post": "Commented on a post you observe …", "commented_on_post": "Commented on a post you observe",
"followed_user_posted": "Wrote a new post …", "followed_user_posted": "Wrote a new post",
"mentioned_in_comment": "Mentioned you in a comment …", "mentioned_in_comment": "Mentioned you in a comment",
"mentioned_in_post": "Mentioned you in a post …", "mentioned_in_post": "Mentioned you in a post",
"post_in_group": "Posted in a group …", "on_date": "on",
"removed_user_from_group": "Removed you from group …", "post_in_group": "Posted in a group",
"user_joined_group": "Joined your group …", "removed_user_from_group": "Removed you from group",
"user_left_group": "Left your group …" "user_joined_group": "Joined your group",
"user_left_group": "Left your group"
}, },
"title": "Notifications", "title": "Notifications",
"user": "User" "user": "User"

View File

@ -805,10 +805,11 @@
"post": "Contribución", "post": "Contribución",
"reason": { "reason": {
"changed_group_member_role": null, "changed_group_member_role": null,
"commented_on_post": "Comentó su contribución ...", "commented_on_post": "Comentó su contribución",
"followed_user_posted": null, "followed_user_posted": null,
"mentioned_in_comment": "Le mencionó en un comentario …", "mentioned_in_comment": "Le mencionó en un comentario",
"mentioned_in_post": "Le mencionó en una contribución …", "mentioned_in_post": "Le mencionó en una contribución",
"on_date": "de",
"post_in_group": null, "post_in_group": null,
"removed_user_from_group": null, "removed_user_from_group": null,
"user_joined_group": null, "user_joined_group": null,

View File

@ -809,6 +809,7 @@
"followed_user_posted": null, "followed_user_posted": null,
"mentioned_in_comment": "Vous a mentionné dans un commentaire…", "mentioned_in_comment": "Vous a mentionné dans un commentaire…",
"mentioned_in_post": "Vous a mentionné dans un post…", "mentioned_in_post": "Vous a mentionné dans un post…",
"on_date": null,
"post_in_group": null, "post_in_group": null,
"removed_user_from_group": null, "removed_user_from_group": null,
"user_joined_group": null, "user_joined_group": null,

View File

@ -809,6 +809,7 @@
"followed_user_posted": null, "followed_user_posted": null,
"mentioned_in_comment": null, "mentioned_in_comment": null,
"mentioned_in_post": null, "mentioned_in_post": null,
"on_date": null,
"post_in_group": null, "post_in_group": null,
"removed_user_from_group": null, "removed_user_from_group": null,
"user_joined_group": null, "user_joined_group": null,

View File

@ -809,6 +809,7 @@
"followed_user_posted": null, "followed_user_posted": null,
"mentioned_in_comment": null, "mentioned_in_comment": null,
"mentioned_in_post": null, "mentioned_in_post": null,
"on_date": null,
"post_in_group": null, "post_in_group": null,
"removed_user_from_group": null, "removed_user_from_group": null,
"user_joined_group": null, "user_joined_group": null,

View File

@ -809,6 +809,7 @@
"followed_user_posted": null, "followed_user_posted": null,
"mentioned_in_comment": "Mentionou você em um comentário …", "mentioned_in_comment": "Mentionou você em um comentário …",
"mentioned_in_post": "Mencinou você em um post …", "mentioned_in_post": "Mencinou você em um post …",
"on_date": null,
"post_in_group": null, "post_in_group": null,
"removed_user_from_group": null, "removed_user_from_group": null,
"user_joined_group": null, "user_joined_group": null,

View File

@ -809,6 +809,7 @@
"followed_user_posted": null, "followed_user_posted": null,
"mentioned_in_comment": "Упоминание в комментарии....", "mentioned_in_comment": "Упоминание в комментарии....",
"mentioned_in_post": "Упоминание в посте....", "mentioned_in_post": "Упоминание в посте....",
"on_date": null,
"post_in_group": null, "post_in_group": null,
"removed_user_from_group": null, "removed_user_from_group": null,
"user_joined_group": null, "user_joined_group": null,

View File

@ -579,6 +579,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -660,6 +665,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -741,6 +751,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -822,6 +837,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -2455,6 +2475,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -2536,6 +2561,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -2617,6 +2647,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -2698,6 +2733,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a close
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -3434,6 +3474,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -3515,6 +3560,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -3596,6 +3646,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -3677,6 +3732,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -4257,6 +4317,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -4338,6 +4403,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -4419,6 +4489,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -4500,6 +4575,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -5078,6 +5158,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -5159,6 +5244,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -5240,6 +5330,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -5321,6 +5416,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -5969,6 +6069,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -6050,6 +6155,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -6131,6 +6241,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -6212,6 +6327,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a curre
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -6995,6 +7115,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a hidde
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -7076,6 +7201,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a hidde
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -7157,6 +7287,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a hidde
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -7238,6 +7373,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a hidde
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -7974,6 +8114,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a hidde
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -8055,6 +8200,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a hidde
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -8136,6 +8286,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a hidde
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->
@ -8217,6 +8372,11 @@ exports[`GroupProfileSlug given a puplic group "yoga-practice" given a hidde
<!----> <!---->
<!----> <!---->
<span>
<!---->
</span>
</div> </div>
<!----> <!---->

View File

@ -11,6 +11,17 @@
</ds-flex-item> </ds-flex-item>
</ds-flex> </ds-flex>
<ds-space /> <ds-space />
<ds-flex-item class="notifications-header-button" :width="{ base: 'auto' }" centered>
<base-button
primary
:disabled="unreadNotificationsCount === 0"
@click="markAllAsRead"
data-test="markAllAsRead-button"
>
{{ $t('notifications.markAllAsRead') }}
</base-button>
</ds-flex-item>
<ds-space />
<notifications-table <notifications-table
@markNotificationAsRead="markNotificationAsRead" @markNotificationAsRead="markNotificationAsRead"
:notifications="notifications" :notifications="notifications"
@ -25,16 +36,6 @@
@next="next" @next="next"
/> />
</ds-flex-item> </ds-flex-item>
<ds-flex-item class="notifications-footer-button" :width="{ base: 'auto' }" centered>
<base-button
primary
:disabled="unreadNotificationsCount === 0"
@click="markAllAsRead"
data-test="markAllAsRead-button"
>
{{ $t('notifications.markAllAsRead') }}
</base-button>
</ds-flex-item>
</ds-flex> </ds-flex>
</base-card> </base-card>
</template> </template>
@ -153,10 +154,15 @@ export default {
</script> </script>
<style lang="scss"> <style lang="scss">
.notifications-page-flex { .notifications-page-flex {
padding: 8px;
justify-content: space-between; justify-content: space-between;
} }
.notifications-header-button {
text-align: right;
}
.notifications-footer { .notifications-footer {
margin-top: 1.5rem;
justify-content: space-evenly; justify-content: space-evenly;
} }
</style> </style>