Merge pull request #3922 from Ocelot-Social-Community/feature/mark-all-notification-as-read

feat(webapp): 🍰 allows mark all notifications as read
This commit is contained in:
Moriz Wahl 2023-03-09 13:59:02 +01:00 committed by GitHub
commit 0634a08316
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 392 additions and 113 deletions

View File

@ -0,0 +1,65 @@
import gql from 'graphql-tag'
// ------ mutations
export const markAsReadMutation = () => {
return gql`
mutation ($id: ID!) {
markAsRead(id: $id) {
from {
__typename
... on Post {
content
}
... on Comment {
content
}
}
read
createdAt
}
}
`
}
export const markAllAsReadMutation = () => {
return gql`
mutation {
markAllAsRead {
from {
__typename
... on Post {
content
}
... on Comment {
content
}
}
read
createdAt
}
}
`
}
// ------ queries
export const notificationQuery = () => {
return gql`
query ($read: Boolean, $orderBy: NotificationOrdering) {
notifications(read: $read, orderBy: $orderBy) {
from {
__typename
... on Post {
content
}
... on Comment {
content
}
}
read
createdAt
}
}
`
}

View File

@ -449,6 +449,7 @@ export default shield(
blockUser: isAuthenticated,
unblockUser: isAuthenticated,
markAsRead: isAuthenticated,
markAllAsRead: isAuthenticated,
AddEmailAddress: isAuthenticated,
VerifyEmailAddress: isAuthenticated,
pinPost: isAdmin,

View File

@ -99,6 +99,35 @@ export default {
session.close()
}
},
markAllAsRead: async (parent, args, context, resolveInfo) => {
const { user: currentUser } = context
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const markAllNotificationAsReadTransactionResponse = await transaction.run(
`
MATCH (resource)-[notification:NOTIFIED {read: FALSE}]->(user:User {id:$id})
SET notification.read = TRUE
WITH user, notification, resource,
[(resource)<-[:WROTE]-(author:User) | author {.*}] AS authors,
[(resource)-[:COMMENTS]->(post:Post)<-[:WROTE]-(author:User) | post{.*, author: properties(author)} ] AS posts
WITH resource, user, notification, authors, posts,
resource {.*, __typename: labels(resource)[0], author: authors[0], post: posts[0]} AS finalResource
RETURN notification {.*, from: finalResource, to: properties(user)}
`,
{ id: currentUser.id },
)
log(markAllNotificationAsReadTransactionResponse)
return markAllNotificationAsReadTransactionResponse.records.map((record) =>
record.get('notification'),
)
})
try {
const notifications = await writeTxResultPromise
return notifications
} finally {
session.close()
}
},
},
NOTIFIED: {
id: async (parent) => {

View File

@ -3,6 +3,11 @@ import gql from 'graphql-tag'
import { getDriver } from '../../db/neo4j'
import { createTestClient } from 'apollo-server-testing'
import createServer from '../.././server'
import {
markAsReadMutation,
markAllAsReadMutation,
notificationQuery,
} from '../../graphql/notifications'
const driver = getDriver()
let authenticatedUser
@ -146,26 +151,9 @@ describe('given some notifications', () => {
})
describe('notifications', () => {
const notificationQuery = gql`
query ($read: Boolean, $orderBy: NotificationOrdering) {
notifications(read: $read, orderBy: $orderBy) {
from {
__typename
... on Post {
content
}
... on Comment {
content
}
}
read
createdAt
}
}
`
describe('unauthenticated', () => {
it('throws authorization error', async () => {
const { errors } = await query({ query: notificationQuery })
const { errors } = await query({ query: notificationQuery() })
expect(errors[0]).toHaveProperty('message', 'Not Authorized!')
})
})
@ -212,7 +200,7 @@ describe('given some notifications', () => {
},
]
await expect(query({ query: notificationQuery, variables })).resolves.toMatchObject({
await expect(query({ query: notificationQuery(), variables })).resolves.toMatchObject({
data: {
notifications: expect.arrayContaining(expected),
},
@ -246,7 +234,7 @@ describe('given some notifications', () => {
},
})
const response = await query({
query: notificationQuery,
query: notificationQuery(),
variables: { ...variables, read: false },
})
await expect(response).toMatchObject(expected)
@ -275,14 +263,14 @@ describe('given some notifications', () => {
it('reduces notifications list', async () => {
await expect(
query({ query: notificationQuery, variables: { ...variables, read: false } }),
query({ query: notificationQuery(), variables: { ...variables, read: false } }),
).resolves.toMatchObject({
data: { notifications: [expect.any(Object), expect.any(Object)] },
errors: undefined,
})
await deletePostAction()
await expect(
query({ query: notificationQuery, variables: { ...variables, read: false } }),
query({ query: notificationQuery(), variables: { ...variables, read: false } }),
).resolves.toMatchObject({ data: { notifications: [] }, errors: undefined })
})
})
@ -291,27 +279,10 @@ describe('given some notifications', () => {
})
describe('markAsRead', () => {
const markAsReadMutation = gql`
mutation ($id: ID!) {
markAsRead(id: $id) {
from {
__typename
... on Post {
content
}
... on Comment {
content
}
}
read
createdAt
}
}
`
describe('unauthenticated', () => {
it('throws authorization error', async () => {
const result = await mutate({
mutation: markAsReadMutation,
mutation: markAsReadMutation(),
variables: { ...variables, id: 'p1' },
})
expect(result.errors[0]).toHaveProperty('message', 'Not Authorized!')
@ -332,7 +303,7 @@ describe('given some notifications', () => {
})
it('returns null', async () => {
const response = await mutate({ mutation: markAsReadMutation, variables })
const response = await mutate({ mutation: markAsReadMutation(), variables })
expect(response.data.markAsRead).toEqual(null)
expect(response.errors).toBeUndefined()
})
@ -348,7 +319,7 @@ describe('given some notifications', () => {
})
it('updates `read` attribute and returns NOTIFIED relationship', async () => {
const { data } = await mutate({ mutation: markAsReadMutation, variables })
const { data } = await mutate({ mutation: markAsReadMutation(), variables })
expect(data).toEqual({
markAsRead: {
from: {
@ -369,7 +340,7 @@ describe('given some notifications', () => {
}
})
it('returns null', async () => {
const response = await mutate({ mutation: markAsReadMutation, variables })
const response = await mutate({ mutation: markAsReadMutation(), variables })
expect(response.data.markAsRead).toEqual(null)
expect(response.errors).toBeUndefined()
})
@ -385,7 +356,7 @@ describe('given some notifications', () => {
})
it('updates `read` attribute and returns NOTIFIED relationship', async () => {
const { data } = await mutate({ mutation: markAsReadMutation, variables })
const { data } = await mutate({ mutation: markAsReadMutation(), variables })
expect(data).toEqual({
markAsRead: {
from: {
@ -401,4 +372,46 @@ describe('given some notifications', () => {
})
})
})
describe('markAllAsRead', () => {
describe('unauthenticated', () => {
it('throws authorization error', async () => {
const result = await mutate({
mutation: markAllAsReadMutation(),
})
expect(result.errors[0]).toHaveProperty('message', 'Not Authorized!')
})
})
describe('authenticated', () => {
beforeEach(async () => {
authenticatedUser = await user.toJson()
})
describe('not being notified at all', () => {
beforeEach(async () => {
variables = {
...variables,
}
})
it('returns all as read', async () => {
const response = await mutate({ mutation: markAllAsReadMutation(), variables })
expect(response.data.markAllAsRead).toEqual([
{
createdAt: '2019-08-30T19:33:48.651Z',
from: { __typename: 'Comment', content: 'You have been mentioned in a comment' },
read: true,
},
{
createdAt: '2019-08-31T17:33:48.651Z',
from: { __typename: 'Post', content: 'You have been mentioned in a post' },
read: true,
},
])
expect(response.errors).toBeUndefined()
})
})
})
})
})

View File

@ -29,6 +29,7 @@ type Query {
type Mutation {
markAsRead(id: ID!): NOTIFIED
markAllAsRead: [NOTIFIED]
}
type Subscription {

View File

@ -55,7 +55,7 @@ Start Neo4J and confirm the database is running at [http://localhost:7474](http:
Here we describe some rarely used Cypher commands for Neo4j that are needed from time to time:
### Index And Contraint Commands
### Index And Constraint Commands
If indexes or constraints are missing or not set correctly, the browser search will not work or the database seed for development will not work.

View File

@ -3,7 +3,7 @@
<a href="#" slot="default" slot-scope="{ toggleMenu }" @click.prevent="toggleMenu()">
<ds-text bold size="large">{{ $t('admin.categories.name') }}</ds-text>
</a>
<template slot="popover">
<template #popover>
<div class="category-menu-options">
<h2 class="title">{{ $t('filter-menu.filter-by') }}</h2>
<categories-filter v-if="categoriesActive" />

View File

@ -10,7 +10,7 @@
>
<base-icon class="dropdown-arrow" name="angle-down" />
</base-button>
<template slot="popover">
<template #popover>
<filter-menu-component />
</template>
</dropdown>

View File

@ -12,15 +12,24 @@
<counter-icon icon="bell" :count="unreadNotificationsCount" danger />
</base-button>
</template>
<template slot="popover">
<template #popover>
<div class="notifications-menu-popover">
<notification-list :notifications="notifications" @markAsRead="markAsRead" />
</div>
<div class="notifications-link-container">
<nuxt-link :to="{ name: 'notifications' }">
{{ $t('notifications.pageLink') }}
</nuxt-link>
</div>
<ds-flex class="notifications-link-container">
<ds-flex-item :width="{ base: 'auto' }" centered>
<nuxt-link :to="{ name: 'notifications' }">
<ds-button ghost primary>
{{ $t('notifications.pageLink') }}
</ds-button>
</nuxt-link>
</ds-flex-item>
<ds-flex-item :width="{ base: 'auto' }" centered>
<ds-button ghost primary @click="markAllAsRead" data-test="markAllAsRead-button">
{{ $t('notifications.markAllAsRead') }}
</ds-button>
</ds-flex-item>
</ds-flex>
</template>
</dropdown>
</template>
@ -28,7 +37,12 @@
<script>
import { mapGetters } from 'vuex'
import unionBy from 'lodash/unionBy'
import { notificationQuery, markAsReadMutation, notificationAdded } from '~/graphql/User'
import {
notificationQuery,
markAsReadMutation,
notificationAdded,
markAllAsReadMutation,
} from '~/graphql/User'
import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon'
import Dropdown from '~/components/Dropdown'
import NotificationList from '../NotificationList/NotificationList'
@ -56,8 +70,21 @@ export default {
mutation: markAsReadMutation(this.$i18n),
variables,
})
} catch (err) {
this.$toast.error(err.message)
} catch (error) {
this.$toast.error(error.message)
}
},
async markAllAsRead() {
if (!this.hasNotifications) {
return
}
try {
await this.$apollo.mutate({
mutation: markAllAsReadMutation(this.$i18n),
})
} catch (error) {
this.$toast.error(error.message)
}
},
},
@ -71,6 +98,9 @@ export default {
}, 0)
return result
},
hasNotifications() {
return this.notifications.length
},
},
apollo: {
notifications: {
@ -118,7 +148,7 @@ export default {
}
.notifications-link-container {
background-color: $background-color-softer-active;
text-align: center;
justify-content: center;
position: fixed;
bottom: 0;
left: 0;

View File

@ -108,7 +108,7 @@ export const mapUserQuery = (i18n) => {
`
}
export const notificationQuery = (i18n) => {
export const notificationQuery = (_i18n) => {
return gql`
${userFragment}
${commentFragment}
@ -147,7 +147,7 @@ export const notificationQuery = (i18n) => {
`
}
export const markAsReadMutation = (i18n) => {
export const markAsReadMutation = (_i18n) => {
return gql`
${userFragment}
${commentFragment}
@ -183,6 +183,42 @@ export const markAsReadMutation = (i18n) => {
`
}
export const markAllAsReadMutation = (_i18n) => {
return gql`
${userFragment}
${commentFragment}
${postFragment}
mutation {
markAllAsRead {
id
read
reason
createdAt
updatedAt
from {
__typename
... on Post {
...post
author {
...user
}
}
... on Comment {
...comment
post {
...post
author {
...user
}
}
}
}
}
}
`
}
export const notificationAdded = () => {
return gql`
${userFragment}

View File

@ -640,6 +640,7 @@
"read": "Gelesen",
"unread": "Ungelesen"
},
"markAllAsRead": "Markiere alle als gelesen",
"pageLink": "Alle Benachrichtigungen",
"post": "Beitrag",
"reason": {

View File

@ -640,6 +640,7 @@
"read": "Read",
"unread": "Unread"
},
"markAllAsRead": "Mark all as read",
"pageLink": "All notifications",
"post": "Post",
"reason": {

View File

@ -412,6 +412,7 @@
"read": "Leído",
"unread": "No leído"
},
"markAllAsRead": "Marcar todas como leido",
"pageLink": "Todas las notificaciones",
"post": "Contribución",
"reason": {

View File

@ -401,6 +401,7 @@
"read": "Lire",
"unread": "Non lu"
},
"markAllAsRead": "Tout marquer comme lu",
"pageLink": "Toutes les notifications",
"post": "Post",
"reason": {

View File

@ -354,6 +354,7 @@
"read": null,
"unread": null
},
"markAllAsRead": "Segna tutti come letti",
"pageLink": null,
"post": null,
"reason": {

View File

@ -100,6 +100,26 @@
"moreInfo": "Wat is {APPLICATION_NAME}?",
"password": "Uw Wachtwoord"
},
"notifications": {
"comment": null,
"content": null,
"empty": null,
"filterLabel": {
"all": null,
"read": null,
"unread": null
},
"markAllAsRead": "Markeer alles als gelezen",
"pageLink": null,
"post": null,
"reason": {
"commented_on_post": null,
"mentioned_in_comment": null,
"mentioned_in_post": null
},
"title": null,
"user": null
},
"post": {
"moreInfo": {
"name": "Meer info"

View File

@ -199,6 +199,7 @@
}
},
"notifications": {
"markAllAsRead": "Oznacz wszystkie jako przeczytane",
"menu": {
"mentioned": "wspomiał o Tobie we wpisie"
}

View File

@ -390,6 +390,7 @@
"read": "Lido",
"unread": "Não lido"
},
"markAllAsRead": "Marcar todas como lidas",
"pageLink": "Todas as notificações",
"post": "Post",
"reason": {

View File

@ -426,6 +426,7 @@
"read": "Прочитанные",
"unread": "Непрочитанные"
},
"markAllAsRead": "Отметить все как прочитанное",
"pageLink": "Все уведомления",
"post": "Пост",
"reason": {

View File

@ -1,21 +1,22 @@
import { shallowMount, mount } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import NotificationsPage from './index.vue'
import DropdownFilter from '~/components/DropdownFilter/DropdownFilter'
import NotificationsTable from '~/components/NotificationsTable/NotificationsTable'
import PaginationButtons from '~/components/_new/generic/PaginationButtons/PaginationButtons'
import { markAsReadMutation, markAllAsReadMutation } from '~/graphql/User'
const localVue = global.localVue
const stubs = {
'client-only': true,
'notifications-table': true,
}
describe('PostIndex', () => {
let wrapper, Wrapper, mocks, propsData
let wrapper, Wrapper, mocks
beforeEach(() => {
propsData = {}
mocks = {
$t: (string) => string,
$toast: {
@ -37,86 +38,94 @@ describe('PostIndex', () => {
}
})
describe('shallowMount', () => {
beforeEach(() => {
Wrapper = () => {
return shallowMount(NotificationsPage, {
mocks,
localVue,
propsData,
stubs,
})
}
wrapper = Wrapper()
})
it('renders a Notications header', () => {
expect(wrapper.find('ds-heading-stub').exists()).toBe(true)
})
it('renders a `dropdown-filter` component', () => {
expect(wrapper.find('dropdown-filter-stub').exists()).toBe(true)
})
it('renders a `notifications-table` component', () => {
expect(wrapper.find('notifications-table-stub').exists()).toBe(true)
})
})
describe('mount', () => {
jest.clearAllMocks()
beforeEach(() => {
Wrapper = () => {
return mount(NotificationsPage, {
mocks,
localVue,
propsData,
stubs,
})
}
wrapper = Wrapper()
wrapper.setData({
notifications: [
{
id: 'mentioned_in_comment/c4-1/u1',
read: false,
reason: 'mentioned_in_comment',
createdAt: '2023-03-06T14:32:47.924Z',
updatedAt: '2023-03-06T14:32:47.924Z',
},
{
id: 'mentioned_in_post/p8/u1',
read: false,
reason: 'mentioned_in_post',
createdAt: '2023-03-06T14:32:47.667Z',
updatedAt: '2023-03-06T14:32:47.667Z',
},
],
})
})
it('renders a Notications header', () => {
expect(wrapper.find('.ds-heading').exists()).toBe(true)
})
it('renders a `dropdown-filter` component', () => {
expect(wrapper.find('.dropdown-filter').exists()).toBe(true)
})
it('renders a `notifications-table` component', () => {
expect(wrapper.findComponent(NotificationsTable).exists()).toBe(true)
})
it('renders a `mark-all-as-read` button', () => {
expect(wrapper.find('[data-test="markAllAsRead-button"]').exists()).toBe(true)
})
describe('filter', () => {
beforeEach(() => {
propsData.filterOptions = [
{ label: 'All', value: null },
{ label: 'Read', value: true },
{ label: 'Unread', value: false },
]
wrapper = Wrapper()
wrapper.findComponent(DropdownFilter).vm.$emit('filter', propsData.filterOptions[1])
it('has "All" as default', () => {
expect(wrapper.find('a.dropdown-filter').text()).toBe('notifications.filterLabel.all')
})
it('sets `notificationRead` to value of received option', () => {
expect(wrapper.vm.notificationRead).toEqual(propsData.filterOptions[1].value)
})
describe('select Read', () => {
beforeEach(() => {
wrapper.findComponent(DropdownFilter).vm.$emit('filter', wrapper.vm.filterOptions[1])
})
it('set label to the label of the received option', () => {
expect(wrapper.vm.selected).toEqual(propsData.filterOptions[1].label)
})
it('sets `notificationRead` to value of received option', () => {
expect(wrapper.vm.notificationRead).toEqual(wrapper.vm.filterOptions[1].value)
})
it('refreshes the notifications', () => {
expect(mocks.$apollo.queries.notifications.refresh).toHaveBeenCalledTimes(1)
it('sets label to the label of the received option', () => {
expect(wrapper.vm.selected).toEqual(wrapper.vm.filterOptions[1].label)
})
it('refreshes the notifications', () => {
expect(mocks.$apollo.queries.notifications.refresh).toHaveBeenCalledTimes(1)
})
})
})
describe('markNotificationAsRead', () => {
beforeEach(() => {
wrapper = Wrapper()
wrapper
.findComponent(NotificationsTable)
.vm.$emit('markNotificationAsRead', 'notificationSourceId')
})
it('calls markNotificationAsRead mutation', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(
expect.objectContaining({ variables: { id: 'notificationSourceId' } }),
)
it('calls markAllAsRead mutation', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledWith({
mutation: markAsReadMutation(),
variables: { id: 'notificationSourceId' },
})
})
describe('error handling', () => {
beforeEach(() => {
mocks.$apollo.mutate = jest.fn().mockRejectedValueOnce({ message: 'Some error message' })
wrapper = Wrapper()
wrapper
.findComponent(NotificationsTable)
.vm.$emit('markNotificationAsRead', 'notificationSourceId')
@ -128,6 +137,26 @@ describe('PostIndex', () => {
})
})
describe('markAllNotificationAsRead', () => {
it('calls markAllNotificationAsRead mutation and refreshes notification', async () => {
wrapper.find('button[data-test="markAllAsRead-button"]').trigger('click')
await expect(mocks.$apollo.mutate).toHaveBeenCalledWith({
mutation: markAllAsReadMutation(),
})
expect(mocks.$apollo.queries.notifications.refresh).toHaveBeenCalledTimes(1)
})
describe('error handling', () => {
it('shows an error message if there is an error', async () => {
mocks.$apollo.mutate = jest
.fn()
.mockRejectedValueOnce({ message: 'Another error message' })
await wrapper.find('button[data-test="markAllAsRead-button"]').trigger('click')
expect(mocks.$toast.error).toHaveBeenCalledWith('Another error message')
})
})
})
describe('PaginationButtons', () => {
beforeEach(() => {
wrapper = Wrapper()

View File

@ -15,7 +15,27 @@
@markNotificationAsRead="markNotificationAsRead"
:notifications="notifications"
/>
<pagination-buttons :hasNext="hasNext" :hasPrevious="hasPrevious" @back="back" @next="next" />
<ds-flex class="notifications-footer">
<ds-flex-item :width="{ base: 'auto' }" centered>
<pagination-buttons
:hasNext="hasNext"
:hasPrevious="hasPrevious"
@back="back"
@next="next"
/>
</ds-flex-item>
<ds-flex-item class="notifications-footer-button" :width="{ base: 'auto' }" centered>
<ds-button
primary
:disabled="unreadNotificationsCount === 0"
@click="markAllAsRead"
data-test="markAllAsRead-button"
>
{{ $t('notifications.markAllAsRead') }}
</ds-button>
</ds-flex-item>
</ds-flex>
</base-card>
</template>
@ -23,7 +43,7 @@
import NotificationsTable from '~/components/NotificationsTable/NotificationsTable'
import DropdownFilter from '~/components/DropdownFilter/DropdownFilter'
import PaginationButtons from '~/components/_new/generic/PaginationButtons/PaginationButtons'
import { notificationQuery, markAsReadMutation } from '~/graphql/User'
import { notificationQuery, markAsReadMutation, markAllAsReadMutation } from '~/graphql/User'
export default {
components: {
@ -54,6 +74,15 @@ export default {
{ label: this.$t('notifications.filterLabel.unread'), value: false },
]
},
hasNotifications() {
return this.notifications.length
},
unreadNotificationsCount() {
const result = this.notifications.reduce((count, notification) => {
return notification.read ? count : count + 1
}, 0)
return result
},
},
methods: {
filter(option) {
@ -77,6 +106,20 @@ export default {
next() {
this.offset += this.pageSize
},
async markAllAsRead() {
if (!this.hasNotifications) {
return
}
try {
await this.$apollo.mutate({
mutation: markAllAsReadMutation(this.$i18n),
})
this.$apollo.queries.notifications.refresh()
} catch (error) {
this.$toast.error(error.message)
}
},
},
apollo: {
notifications: {
@ -112,4 +155,8 @@ export default {
.notifications-page-flex {
justify-content: space-between;
}
.notifications-footer {
justify-content: space-evenly;
}
</style>