Merge branch 'master' of github.com:Ocelot-Social-Community/Ocelot-Social into 5627-fix-responsive-view-on-iphone

This commit is contained in:
Wolfgang Huß 2023-03-14 07:39:31 +01:00
commit 2d0a60da58
30 changed files with 433 additions and 134 deletions

View File

@ -46,6 +46,11 @@ jobs:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
steps: steps:
- name: Checkout code
uses: actions/checkout@v2
with:
ref: ${{ github.event.client_payload.ref }}
- name: Download Docker Image (Backend) - name: Download Docker Image (Backend)
uses: actions/download-artifact@v2 uses: actions/download-artifact@v2
with: with:

View File

@ -293,7 +293,7 @@ jobs:
echo "BUILD_COMMIT=${GITHUB_SHA}" >> $GITHUB_ENV echo "BUILD_COMMIT=${GITHUB_SHA}" >> $GITHUB_ENV
- run: echo "BUILD_VERSION=${VERSION}-${GITHUB_RUN_NUMBER}" >> $GITHUB_ENV - run: echo "BUILD_VERSION=${VERSION}-${GITHUB_RUN_NUMBER}" >> $GITHUB_ENV
- name: Repository Dispatch - name: Repository Dispatch
uses: peter-evans/repository-dispatch@v1 uses: peter-evans/repository-dispatch@v2
with: with:
token: ${{ github.token }} token: ${{ github.token }}
event-type: trigger-build-success event-type: trigger-build-success

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, blockUser: isAuthenticated,
unblockUser: isAuthenticated, unblockUser: isAuthenticated,
markAsRead: isAuthenticated, markAsRead: isAuthenticated,
markAllAsRead: isAuthenticated,
AddEmailAddress: isAuthenticated, AddEmailAddress: isAuthenticated,
VerifyEmailAddress: isAuthenticated, VerifyEmailAddress: isAuthenticated,
pinPost: isAdmin, pinPost: isAdmin,

View File

@ -99,6 +99,35 @@ export default {
session.close() 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: { NOTIFIED: {
id: async (parent) => { id: async (parent) => {

View File

@ -3,6 +3,11 @@ import gql from 'graphql-tag'
import { getDriver } from '../../db/neo4j' import { getDriver } from '../../db/neo4j'
import { createTestClient } from 'apollo-server-testing' import { createTestClient } from 'apollo-server-testing'
import createServer from '../.././server' import createServer from '../.././server'
import {
markAsReadMutation,
markAllAsReadMutation,
notificationQuery,
} from '../../graphql/notifications'
const driver = getDriver() const driver = getDriver()
let authenticatedUser let authenticatedUser
@ -146,26 +151,9 @@ describe('given some notifications', () => {
}) })
describe('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', () => { describe('unauthenticated', () => {
it('throws authorization error', async () => { it('throws authorization error', async () => {
const { errors } = await query({ query: notificationQuery }) const { errors } = await query({ query: notificationQuery() })
expect(errors[0]).toHaveProperty('message', 'Not Authorized!') 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: { data: {
notifications: expect.arrayContaining(expected), notifications: expect.arrayContaining(expected),
}, },
@ -246,7 +234,7 @@ describe('given some notifications', () => {
}, },
}) })
const response = await query({ const response = await query({
query: notificationQuery, query: notificationQuery(),
variables: { ...variables, read: false }, variables: { ...variables, read: false },
}) })
await expect(response).toMatchObject(expected) await expect(response).toMatchObject(expected)
@ -275,14 +263,14 @@ describe('given some notifications', () => {
it('reduces notifications list', async () => { it('reduces notifications list', async () => {
await expect( await expect(
query({ query: notificationQuery, variables: { ...variables, read: false } }), query({ query: notificationQuery(), variables: { ...variables, read: false } }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
data: { notifications: [expect.any(Object), expect.any(Object)] }, data: { notifications: [expect.any(Object), expect.any(Object)] },
errors: undefined, errors: undefined,
}) })
await deletePostAction() await deletePostAction()
await expect( await expect(
query({ query: notificationQuery, variables: { ...variables, read: false } }), query({ query: notificationQuery(), variables: { ...variables, read: false } }),
).resolves.toMatchObject({ data: { notifications: [] }, errors: undefined }) ).resolves.toMatchObject({ data: { notifications: [] }, errors: undefined })
}) })
}) })
@ -291,27 +279,10 @@ describe('given some notifications', () => {
}) })
describe('markAsRead', () => { describe('markAsRead', () => {
const markAsReadMutation = gql`
mutation ($id: ID!) {
markAsRead(id: $id) {
from {
__typename
... on Post {
content
}
... on Comment {
content
}
}
read
createdAt
}
}
`
describe('unauthenticated', () => { describe('unauthenticated', () => {
it('throws authorization error', async () => { it('throws authorization error', async () => {
const result = await mutate({ const result = await mutate({
mutation: markAsReadMutation, mutation: markAsReadMutation(),
variables: { ...variables, id: 'p1' }, variables: { ...variables, id: 'p1' },
}) })
expect(result.errors[0]).toHaveProperty('message', 'Not Authorized!') expect(result.errors[0]).toHaveProperty('message', 'Not Authorized!')
@ -332,7 +303,7 @@ describe('given some notifications', () => {
}) })
it('returns null', async () => { 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.data.markAsRead).toEqual(null)
expect(response.errors).toBeUndefined() expect(response.errors).toBeUndefined()
}) })
@ -348,7 +319,7 @@ describe('given some notifications', () => {
}) })
it('updates `read` attribute and returns NOTIFIED relationship', async () => { 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({ expect(data).toEqual({
markAsRead: { markAsRead: {
from: { from: {
@ -369,7 +340,7 @@ describe('given some notifications', () => {
} }
}) })
it('returns null', async () => { 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.data.markAsRead).toEqual(null)
expect(response.errors).toBeUndefined() expect(response.errors).toBeUndefined()
}) })
@ -385,7 +356,7 @@ describe('given some notifications', () => {
}) })
it('updates `read` attribute and returns NOTIFIED relationship', async () => { 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({ expect(data).toEqual({
markAsRead: { markAsRead: {
from: { 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 { type Mutation {
markAsRead(id: ID!): NOTIFIED markAsRead(id: ID!): NOTIFIED
markAllAsRead: [NOTIFIED]
} }
type Subscription { 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: 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. 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()"> <a href="#" slot="default" slot-scope="{ toggleMenu }" @click.prevent="toggleMenu()">
<ds-text bold size="large">{{ $t('admin.categories.name') }}</ds-text> <ds-text bold size="large">{{ $t('admin.categories.name') }}</ds-text>
</a> </a>
<template slot="popover"> <template #popover>
<div class="category-menu-options"> <div class="category-menu-options">
<h2 class="title">{{ $t('filter-menu.filter-by') }}</h2> <h2 class="title">{{ $t('filter-menu.filter-by') }}</h2>
<categories-filter v-if="categoriesActive" /> <categories-filter v-if="categoriesActive" />

View File

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

View File

@ -1,7 +1,16 @@
<template> <template>
<dropdown class="invite-button" offset="8" :placement="placement"> <dropdown class="invite-button" offset="8" :placement="placement">
<template #default="{ toggleMenu }"> <template #default="{ toggleMenu }">
<base-button icon="user-plus" circle ghost @click.prevent="toggleMenu" /> <base-button
icon="user-plus"
circle
ghost
v-tooltip="{
content: $t('invite-codes.button.tooltip'),
placement: 'bottom-start',
}"
@click.prevent="toggleMenu"
/>
</template> </template>
<template #popover> <template #popover>
<div class="invite-button-menu-popover"> <div class="invite-button-menu-popover">
@ -15,10 +24,7 @@
ghost ghost
@click="copyInviteLink" @click="copyInviteLink"
> >
<ds-text bold> <ds-text bold>{{ $t('invite-codes.copy-code') }}</ds-text>
{{ $t('invite-codes.copy-code') }}
{{ inviteCode.code }}
</ds-text>
</base-button> </base-button>
</base-card> </base-card>
</div> </div>
@ -108,6 +114,6 @@ export default {
} }
.invite-code { .invite-code {
left: 50%; margin-left: 25%;
} }
</style> </style>

View File

@ -30,7 +30,7 @@ export default {
/* dirty fix to override broken styleguide inline-styles */ /* dirty fix to override broken styleguide inline-styles */
.ds-grid { .ds-grid {
grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)) !important; grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)) !important;
gap: 16px !important; gap: 32px 16px !important;
grid-auto-rows: 20px; grid-auto-rows: 20px;
} }

View File

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

View File

@ -42,7 +42,7 @@
</div> </div>
<div v-else class="categories-placeholder"></div> <div v-else class="categories-placeholder"></div>
<counter-icon <counter-icon
icon="bullhorn" icon="heart-o"
:count="post.shoutedCount" :count="post.shoutedCount"
:title="$t('contribution.amount-shouts', { amount: post.shoutedCount })" :title="$t('contribution.amount-shouts', { amount: post.shoutedCount })"
/> />

View File

@ -4,7 +4,7 @@
:loading="loading" :loading="loading"
:disabled="disabled" :disabled="disabled"
:filled="shouted" :filled="shouted"
icon="bullhorn" icon="heart-o"
circle circle
@click="toggle" @click="toggle"
/> />

View File

@ -21,7 +21,7 @@ storiesOf('Generic/BaseButton', module)
template: ` template: `
<div> <div>
<base-button icon="edit">With Text</base-button> <base-button icon="edit">With Text</base-button>
<base-button icon="bullhorn" /> <base-button icon="heart-o" />
<base-button icon="trash" disabled /> <base-button icon="trash" disabled />
<base-button icon="trash" loading /> <base-button icon="trash" loading />
</div> </div>

View File

@ -4,7 +4,7 @@
<div class="metadata"> <div class="metadata">
<span class="counts"> <span class="counts">
<counter-icon icon="comments" :count="option.commentsCount" soft /> <counter-icon icon="comments" :count="option.commentsCount" soft />
<counter-icon icon="bullhorn" :count="option.shoutedCount" soft /> <counter-icon icon="heart-o" :count="option.shoutedCount" soft />
<counter-icon icon="hand-pointer" :count="option.clickedCount" soft /> <counter-icon icon="hand-pointer" :count="option.clickedCount" soft />
<counter-icon icon="eye" :count="option.viewedTeaserCount" soft /> <counter-icon icon="eye" :count="option.viewedTeaserCount" soft />
</span> </span>

View File

@ -108,7 +108,7 @@ export const mapUserQuery = (i18n) => {
` `
} }
export const notificationQuery = (i18n) => { export const notificationQuery = (_i18n) => {
return gql` return gql`
${userFragment} ${userFragment}
${commentFragment} ${commentFragment}
@ -147,7 +147,7 @@ export const notificationQuery = (i18n) => {
` `
} }
export const markAsReadMutation = (i18n) => { export const markAsReadMutation = (_i18n) => {
return gql` return gql`
${userFragment} ${userFragment}
${commentFragment} ${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 = () => { export const notificationAdded = () => {
return gql` return gql`
${userFragment} ${userFragment}

View File

@ -270,7 +270,7 @@
"filterFollow": "Beiträge von Nutzern filtern, denen ich folge", "filterFollow": "Beiträge von Nutzern filtern, denen ich folge",
"filterMasonryGrid": { "filterMasonryGrid": {
"myFriends": "Nutzer denen ich folge", "myFriends": "Nutzer denen ich folge",
"myGroups": "Meine Gruppen", "myGroups": "Aus meinen Gruppen",
"myTopics": "Meine Themen", "myTopics": "Meine Themen",
"noFilter": "Beiträge filtern" "noFilter": "Beiträge filtern"
}, },
@ -515,10 +515,13 @@
"no-results": "Keine Beiträge gefunden." "no-results": "Keine Beiträge gefunden."
}, },
"invite-codes": { "invite-codes": {
"copy-code": "Code:", "button": {
"tooltip": "Lade deine Freunde ein"
},
"copy-code": "Einladungslink kopieren",
"copy-success": "Einladungscode erfolgreich in die Zwischenablage kopiert", "copy-success": "Einladungscode erfolgreich in die Zwischenablage kopiert",
"not-available": "Du hast keinen Einladungscode zur Verfügung!", "not-available": "Du hast keinen Einladungscode zur Verfügung!",
"your-code": "Kopiere deinen Einladungscode in die Ablage:" "your-code": "Sende diesen Link per E-Mail oder in sozialen Medien, um deine Freunde einzuladen:"
}, },
"login": { "login": {
"email": "Deine E-Mail", "email": "Deine E-Mail",
@ -640,6 +643,7 @@
"read": "Gelesen", "read": "Gelesen",
"unread": "Ungelesen" "unread": "Ungelesen"
}, },
"markAllAsRead": "Markiere alle als gelesen",
"pageLink": "Alle Benachrichtigungen", "pageLink": "Alle Benachrichtigungen",
"post": "Beitrag", "post": "Beitrag",
"reason": { "reason": {

View File

@ -270,7 +270,7 @@
"filterFollow": "Filter contributions from users I follow", "filterFollow": "Filter contributions from users I follow",
"filterMasonryGrid": { "filterMasonryGrid": {
"myFriends": "Users I follow", "myFriends": "Users I follow",
"myGroups": "My groups", "myGroups": "By my groups",
"myTopics": "My topics", "myTopics": "My topics",
"noFilter": "Filter posts" "noFilter": "Filter posts"
}, },
@ -515,10 +515,13 @@
"no-results": "No contributions found." "no-results": "No contributions found."
}, },
"invite-codes": { "invite-codes": {
"copy-code": "Code:", "button": {
"tooltip": "Invite your friends"
},
"copy-code": "Copy Invite Link",
"copy-success": "Invite code copied to clipboard", "copy-success": "Invite code copied to clipboard",
"not-available": "You have no valid invite code available!", "not-available": "You have no valid invite code available!",
"your-code": "Copy your invite code to the clipboard:" "your-code": "Send this link per e-mail or in social media to invite your friends:"
}, },
"login": { "login": {
"email": "Your E-mail", "email": "Your E-mail",
@ -640,6 +643,7 @@
"read": "Read", "read": "Read",
"unread": "Unread" "unread": "Unread"
}, },
"markAllAsRead": "Mark all as read",
"pageLink": "All notifications", "pageLink": "All notifications",
"post": "Post", "post": "Post",
"reason": { "reason": {

View File

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

View File

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

View File

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

View File

@ -100,6 +100,26 @@
"moreInfo": "Wat is {APPLICATION_NAME}?", "moreInfo": "Wat is {APPLICATION_NAME}?",
"password": "Uw Wachtwoord" "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": { "post": {
"moreInfo": { "moreInfo": {
"name": "Meer info" "name": "Meer info"

View File

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

View File

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

View File

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

View File

@ -92,16 +92,16 @@
</div> </div>
</div> </div>
</ds-grid-item> </ds-grid-item>
<ds-space :margin-bottom="{ base: 'small', md: 'base', lg: 'large' }" /> <!-- Placeholder/Space Row -->
<ds-grid-item :row-span="1" column-span="fullWidth" />
<!-- hashtag filter -->
<ds-grid-item v-if="hashtag" :row-span="2" column-span="fullWidth"> <ds-grid-item v-if="hashtag" :row-span="2" column-span="fullWidth">
<hashtags-filter :hashtag="hashtag" @clearSearch="clearSearch" /> <hashtags-filter :hashtag="hashtag" @clearSearch="clearSearch" />
</ds-grid-item> </ds-grid-item>
<ds-space :margin-bottom="{ base: 'small', md: 'base', lg: 'large' }" />
<!-- donation info --> <!-- donation info -->
<ds-grid-item v-if="showDonations" class="top-info-bar" :row-span="1" column-span="fullWidth"> <ds-grid-item v-if="showDonations" class="top-info-bar" :row-span="1" column-span="fullWidth">
<donation-info :goal="goal" :progress="progress" /> <donation-info :goal="goal" :progress="progress" />
</ds-grid-item> </ds-grid-item>
<ds-space :margin-bottom="{ base: 'small', md: 'base', lg: 'large' }" />
<!-- news feed --> <!-- news feed -->
<template v-if="hasResults"> <template v-if="hasResults">
<masonry-grid-item <masonry-grid-item
@ -418,5 +418,8 @@ export default {
font-size: 23px; font-size: 23px;
z-index: 10; z-index: 10;
} }
.ds-grid {
padding-top: 1em;
}
} }
</style> </style>

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 NotificationsPage from './index.vue'
import DropdownFilter from '~/components/DropdownFilter/DropdownFilter' import DropdownFilter from '~/components/DropdownFilter/DropdownFilter'
import NotificationsTable from '~/components/NotificationsTable/NotificationsTable' import NotificationsTable from '~/components/NotificationsTable/NotificationsTable'
import PaginationButtons from '~/components/_new/generic/PaginationButtons/PaginationButtons' import PaginationButtons from '~/components/_new/generic/PaginationButtons/PaginationButtons'
import { markAsReadMutation, markAllAsReadMutation } from '~/graphql/User'
const localVue = global.localVue const localVue = global.localVue
const stubs = { const stubs = {
'client-only': true, 'client-only': true,
'notifications-table': true,
} }
describe('PostIndex', () => { describe('PostIndex', () => {
let wrapper, Wrapper, mocks, propsData let wrapper, Wrapper, mocks
beforeEach(() => { beforeEach(() => {
propsData = {}
mocks = { mocks = {
$t: (string) => string, $t: (string) => string,
$toast: { $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', () => { describe('mount', () => {
jest.clearAllMocks()
beforeEach(() => { beforeEach(() => {
Wrapper = () => { Wrapper = () => {
return mount(NotificationsPage, { return mount(NotificationsPage, {
mocks, mocks,
localVue, localVue,
propsData,
stubs, 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', () => { describe('filter', () => {
it('has "All" as default', () => {
expect(wrapper.find('a.dropdown-filter').text()).toBe('notifications.filterLabel.all')
})
describe('select Read', () => {
beforeEach(() => { beforeEach(() => {
propsData.filterOptions = [ wrapper.findComponent(DropdownFilter).vm.$emit('filter', wrapper.vm.filterOptions[1])
{ label: 'All', value: null },
{ label: 'Read', value: true },
{ label: 'Unread', value: false },
]
wrapper = Wrapper()
wrapper.findComponent(DropdownFilter).vm.$emit('filter', propsData.filterOptions[1])
}) })
it('sets `notificationRead` to value of received option', () => { it('sets `notificationRead` to value of received option', () => {
expect(wrapper.vm.notificationRead).toEqual(propsData.filterOptions[1].value) expect(wrapper.vm.notificationRead).toEqual(wrapper.vm.filterOptions[1].value)
}) })
it('set label to the label of the received option', () => { it('sets label to the label of the received option', () => {
expect(wrapper.vm.selected).toEqual(propsData.filterOptions[1].label) expect(wrapper.vm.selected).toEqual(wrapper.vm.filterOptions[1].label)
}) })
it('refreshes the notifications', () => { it('refreshes the notifications', () => {
expect(mocks.$apollo.queries.notifications.refresh).toHaveBeenCalledTimes(1) expect(mocks.$apollo.queries.notifications.refresh).toHaveBeenCalledTimes(1)
}) })
}) })
})
describe('markNotificationAsRead', () => { describe('markNotificationAsRead', () => {
beforeEach(() => { beforeEach(() => {
wrapper = Wrapper()
wrapper wrapper
.findComponent(NotificationsTable) .findComponent(NotificationsTable)
.vm.$emit('markNotificationAsRead', 'notificationSourceId') .vm.$emit('markNotificationAsRead', 'notificationSourceId')
}) })
it('calls markNotificationAsRead mutation', () => { it('calls markAllAsRead mutation', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledWith( expect(mocks.$apollo.mutate).toHaveBeenCalledWith({
expect.objectContaining({ variables: { id: 'notificationSourceId' } }), mutation: markAsReadMutation(),
) variables: { id: 'notificationSourceId' },
})
}) })
describe('error handling', () => { describe('error handling', () => {
beforeEach(() => { beforeEach(() => {
mocks.$apollo.mutate = jest.fn().mockRejectedValueOnce({ message: 'Some error message' }) mocks.$apollo.mutate = jest.fn().mockRejectedValueOnce({ message: 'Some error message' })
wrapper = Wrapper()
wrapper wrapper
.findComponent(NotificationsTable) .findComponent(NotificationsTable)
.vm.$emit('markNotificationAsRead', 'notificationSourceId') .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', () => { describe('PaginationButtons', () => {
beforeEach(() => { beforeEach(() => {
wrapper = Wrapper() wrapper = Wrapper()

View File

@ -15,7 +15,27 @@
@markNotificationAsRead="markNotificationAsRead" @markNotificationAsRead="markNotificationAsRead"
:notifications="notifications" :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> </base-card>
</template> </template>
@ -23,7 +43,7 @@
import NotificationsTable from '~/components/NotificationsTable/NotificationsTable' import NotificationsTable from '~/components/NotificationsTable/NotificationsTable'
import DropdownFilter from '~/components/DropdownFilter/DropdownFilter' import DropdownFilter from '~/components/DropdownFilter/DropdownFilter'
import PaginationButtons from '~/components/_new/generic/PaginationButtons/PaginationButtons' import PaginationButtons from '~/components/_new/generic/PaginationButtons/PaginationButtons'
import { notificationQuery, markAsReadMutation } from '~/graphql/User' import { notificationQuery, markAsReadMutation, markAllAsReadMutation } from '~/graphql/User'
export default { export default {
components: { components: {
@ -54,6 +74,15 @@ export default {
{ label: this.$t('notifications.filterLabel.unread'), value: false }, { 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: { methods: {
filter(option) { filter(option) {
@ -77,6 +106,20 @@ export default {
next() { next() {
this.offset += this.pageSize 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: { apollo: {
notifications: { notifications: {
@ -112,4 +155,8 @@ export default {
.notifications-page-flex { .notifications-page-flex {
justify-content: space-between; justify-content: space-between;
} }
.notifications-footer {
justify-content: space-evenly;
}
</style> </style>