mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2025-12-12 23:35:58 +00:00
Merge pull request #439 from Human-Connection/347-display_notifications
[WIP] Frontend implementation for notifications
This commit is contained in:
commit
6cd8a4ef21
@ -3,21 +3,26 @@ import uuid from 'uuid/v4'
|
||||
export default function (params) {
|
||||
const {
|
||||
id = uuid(),
|
||||
key,
|
||||
key = '',
|
||||
type = 'crowdfunding',
|
||||
status = 'permanent',
|
||||
icon
|
||||
icon = '/img/badges/indiegogo_en_panda.svg'
|
||||
} = params
|
||||
|
||||
return `
|
||||
mutation {
|
||||
CreateBadge(
|
||||
id: "${id}",
|
||||
key: "${key}",
|
||||
type: ${type},
|
||||
status: ${status},
|
||||
icon: "${icon}"
|
||||
) { id }
|
||||
return {
|
||||
mutation: `
|
||||
mutation(
|
||||
$id: ID
|
||||
$key: String!
|
||||
$type: BadgeTypeEnum!
|
||||
$status: BadgeStatusEnum!
|
||||
$icon: String!
|
||||
) {
|
||||
CreateBadge(id: $id, key: $key, type: $type, status: $status, icon: $icon) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { id, key, type, status, icon }
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
@ -8,14 +8,15 @@ export default function (params) {
|
||||
icon
|
||||
} = params
|
||||
|
||||
return `
|
||||
mutation {
|
||||
CreateCategory(
|
||||
id: "${id}",
|
||||
name: "${name}",
|
||||
slug: "${slug}",
|
||||
icon: "${icon}"
|
||||
) { id, name }
|
||||
return {
|
||||
mutation: `
|
||||
mutation($id: ID, $name: String!, $slug: String, $icon: String!) {
|
||||
CreateCategory(id: $id, name: $name, slug: $slug, icon: $icon) {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`
|
||||
`,
|
||||
variables: { id, name, slug, icon }
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,19 +7,17 @@ export default function (params) {
|
||||
content = [
|
||||
faker.lorem.sentence(),
|
||||
faker.lorem.sentence()
|
||||
].join('. '),
|
||||
disabled = false,
|
||||
deleted = false
|
||||
].join('. ')
|
||||
} = params
|
||||
|
||||
return `
|
||||
mutation {
|
||||
CreateComment(
|
||||
id: "${id}",
|
||||
content: "${content}",
|
||||
disabled: ${disabled},
|
||||
deleted: ${deleted}
|
||||
) { id }
|
||||
}
|
||||
`
|
||||
return {
|
||||
mutation: `
|
||||
mutation($id: ID!, $content: String!) {
|
||||
CreateComment(id: $id, content: $content) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { id, content }
|
||||
}
|
||||
}
|
||||
|
||||
@ -71,8 +71,8 @@ export default function Factory (options = {}) {
|
||||
return this
|
||||
},
|
||||
async create (node, properties) {
|
||||
const mutation = this.factories[node](properties)
|
||||
this.lastResponse = await this.graphQLClient.request(mutation)
|
||||
const { mutation, variables } = this.factories[node](properties)
|
||||
this.lastResponse = await this.graphQLClient.request(mutation, variables)
|
||||
return this
|
||||
},
|
||||
async relate (node, relationship, properties) {
|
||||
|
||||
@ -6,12 +6,15 @@ export default function (params) {
|
||||
read = false
|
||||
} = params
|
||||
|
||||
return `
|
||||
mutation {
|
||||
CreateNotification(
|
||||
id: "${id}",
|
||||
read: ${read},
|
||||
) { id, read }
|
||||
}
|
||||
`
|
||||
return {
|
||||
mutation: `
|
||||
mutation($id: ID, $read: Boolean) {
|
||||
CreateNotification(id: $id, read: $read) {
|
||||
id
|
||||
read
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { id, read }
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,20 +5,17 @@ export default function create (params) {
|
||||
const {
|
||||
id = uuid(),
|
||||
name = faker.company.companyName(),
|
||||
description = faker.company.catchPhrase(),
|
||||
disabled = false,
|
||||
deleted = false
|
||||
description = faker.company.catchPhrase()
|
||||
} = params
|
||||
|
||||
return `
|
||||
mutation {
|
||||
CreateOrganization(
|
||||
id: "${id}",
|
||||
name: "${name}",
|
||||
description: "${description}",
|
||||
disabled: ${disabled},
|
||||
deleted: ${deleted}
|
||||
) { name }
|
||||
}
|
||||
`
|
||||
return {
|
||||
mutation: `
|
||||
mutation($id: ID!, $name: String!, $description: String!) {
|
||||
CreateOrganization(id: $id, name: $name, description: $description) {
|
||||
name
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { id, name, description }
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,17 +18,31 @@ export default function (params) {
|
||||
deleted = false
|
||||
} = params
|
||||
|
||||
return `
|
||||
mutation {
|
||||
CreatePost(
|
||||
id: "${id}",
|
||||
slug: "${slug}",
|
||||
title: "${title}",
|
||||
content: "${content}",
|
||||
image: "${image}",
|
||||
visibility: ${visibility},
|
||||
deleted: ${deleted}
|
||||
) { title, content }
|
||||
}
|
||||
`
|
||||
return {
|
||||
mutation: `
|
||||
mutation(
|
||||
$id: ID!
|
||||
$slug: String
|
||||
$title: String!
|
||||
$content: String!
|
||||
$image: String
|
||||
$visibility: VisibilityEnum
|
||||
$deleted: Boolean
|
||||
) {
|
||||
CreatePost(
|
||||
id: $id
|
||||
slug: $slug
|
||||
title: $title
|
||||
content: $content
|
||||
image: $image
|
||||
visibility: $visibility
|
||||
deleted: $deleted
|
||||
) {
|
||||
title
|
||||
content
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { id, slug, title, content, image, visibility, deleted }
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,15 +6,15 @@ export default function create (params) {
|
||||
id
|
||||
} = params
|
||||
|
||||
return `
|
||||
mutation {
|
||||
report(
|
||||
description: "${description}",
|
||||
id: "${id}",
|
||||
) {
|
||||
id,
|
||||
createdAt
|
||||
return {
|
||||
mutation: `
|
||||
mutation($id: ID!, $description: String!) {
|
||||
report(description: $description, id: $id) {
|
||||
id
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
`,
|
||||
variables: { id, description }
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,15 +3,17 @@ import uuid from 'uuid/v4'
|
||||
export default function (params) {
|
||||
const {
|
||||
id = uuid(),
|
||||
name
|
||||
name = '#human-connection'
|
||||
} = params
|
||||
|
||||
return `
|
||||
mutation {
|
||||
CreateTag(
|
||||
id: "${id}",
|
||||
name: "${name}",
|
||||
) { name }
|
||||
}
|
||||
`
|
||||
return {
|
||||
mutation: `
|
||||
mutation($id: ID!, $name: String!) {
|
||||
CreateTag(id: $id, name: $name) {
|
||||
name
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { id, name }
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,34 +10,42 @@ export default function create (params) {
|
||||
password = '1234',
|
||||
role = 'user',
|
||||
avatar = faker.internet.avatar(),
|
||||
about = faker.lorem.paragraph(),
|
||||
disabled = false,
|
||||
deleted = false
|
||||
about = faker.lorem.paragraph()
|
||||
} = params
|
||||
|
||||
return `
|
||||
mutation {
|
||||
CreateUser(
|
||||
id: "${id}",
|
||||
name: "${name}",
|
||||
slug: "${slug}",
|
||||
password: "${password}",
|
||||
email: "${email}",
|
||||
avatar: "${avatar}",
|
||||
about: "${about}",
|
||||
role: ${role},
|
||||
disabled: ${disabled},
|
||||
deleted: ${deleted}
|
||||
return {
|
||||
mutation: `
|
||||
mutation(
|
||||
$id: ID!
|
||||
$name: String
|
||||
$slug: String
|
||||
$password: String!
|
||||
$email: String
|
||||
$avatar: String
|
||||
$about: String
|
||||
$role: UserGroupEnum
|
||||
) {
|
||||
id
|
||||
name
|
||||
slug
|
||||
email
|
||||
avatar
|
||||
role
|
||||
deleted
|
||||
disabled
|
||||
CreateUser(
|
||||
id: $id
|
||||
name: $name
|
||||
slug: $slug
|
||||
password: $password
|
||||
email: $email
|
||||
avatar: $avatar
|
||||
about: $about
|
||||
role: $role
|
||||
) {
|
||||
id
|
||||
name
|
||||
slug
|
||||
email
|
||||
avatar
|
||||
role
|
||||
deleted
|
||||
disabled
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
`,
|
||||
variables: { id, name, slug, password, email, avatar, about, role }
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import faker from 'faker'
|
||||
import Factory from './factories'
|
||||
|
||||
/* eslint-disable no-multi-spaces */
|
||||
@ -88,20 +89,23 @@ import Factory from './factories'
|
||||
f.create('Tag', { id: 't4', name: 'Freiheit' })
|
||||
])
|
||||
|
||||
const mention1 = 'Hey <a class="mention" href="/profile/u3">@jenny-rostock</a>, what\'s up?'
|
||||
const mention2 = 'Hey <a class="mention" href="/profile/u3">@jenny-rostock</a>, here is another notification for you!'
|
||||
|
||||
await Promise.all([
|
||||
asAdmin.create('Post', { id: 'p0' }),
|
||||
asModerator.create('Post', { id: 'p1' }),
|
||||
asUser.create('Post', { id: 'p2', deleted: true }),
|
||||
asUser.create('Post', { id: 'p2' }),
|
||||
asTick.create('Post', { id: 'p3' }),
|
||||
asTrick.create('Post', { id: 'p4' }),
|
||||
asTrack.create('Post', { id: 'p5' }),
|
||||
asAdmin.create('Post', { id: 'p6' }),
|
||||
asModerator.create('Post', { id: 'p7' }),
|
||||
asModerator.create('Post', { id: 'p7', content: `${mention1} ${faker.lorem.paragraph()}` }),
|
||||
asUser.create('Post', { id: 'p8' }),
|
||||
asTick.create('Post', { id: 'p9' }),
|
||||
asTrick.create('Post', { id: 'p10' }),
|
||||
asTrack.create('Post', { id: 'p11' }),
|
||||
asAdmin.create('Post', { id: 'p12' }),
|
||||
asAdmin.create('Post', { id: 'p12', content: `${mention2} ${faker.lorem.paragraph()}` }),
|
||||
asModerator.create('Post', { id: 'p13' }),
|
||||
asUser.create('Post', { id: 'p14' }),
|
||||
asTick.create('Post', { id: 'p15' })
|
||||
|
||||
@ -1,10 +1,23 @@
|
||||
# End-to-End Testing
|
||||
|
||||
## Configure cypress
|
||||
|
||||
First, you have to tell cypress how to connect to your local neo4j database
|
||||
among other things. You can copy our template configuration and change the new
|
||||
file according to your needs.
|
||||
|
||||
Make sure you are at the root level of the project. Then:
|
||||
```bash
|
||||
# in the top level folder Human-Connection/
|
||||
$ cp cypress.env.template.json cypress.env.json
|
||||
```
|
||||
|
||||
## Run Tests
|
||||
|
||||
To run the tests, make sure you are at the root level of the project, in your console and run the following command:
|
||||
To run the tests, do this:
|
||||
|
||||
```bash
|
||||
# in the top level folder Human-Connection/
|
||||
$ yarn cypress:setup
|
||||
```
|
||||
|
||||
|
||||
@ -83,6 +83,13 @@ The following features will be implemented. This gets done in three steps:
|
||||
* Editing Comments
|
||||
* Upvote comments of others
|
||||
|
||||
### Notifications
|
||||
[Cucumber features](./integration/notifications)
|
||||
|
||||
* User @-mentionings
|
||||
* Notify authors for comments
|
||||
* Administrative notifications to all users
|
||||
|
||||
### Contribution List
|
||||
|
||||
* Show Posts by Tiles
|
||||
|
||||
@ -293,3 +293,43 @@ Then('I can login successfully with password {string}', password => {
|
||||
})
|
||||
cy.get('.iziToast-wrapper').should('contain', "You are logged in!")
|
||||
})
|
||||
|
||||
When('I log in with the following credentials:', table => {
|
||||
const { email, password } = table.hashes()[0]
|
||||
cy.login({ email, password })
|
||||
})
|
||||
|
||||
When('open the notification menu and click on the first item', () => {
|
||||
cy.get('.notifications-menu').click()
|
||||
cy.get('.notification-mention-post').first().click()
|
||||
})
|
||||
|
||||
Then('see {int} unread notifications in the top menu', count => {
|
||||
cy.get('.notifications-menu').should('contain', count)
|
||||
})
|
||||
|
||||
Then('I get to the post page of {string}', path => {
|
||||
path = path.replace('...', '')
|
||||
cy.url().should('contain', '/post/')
|
||||
cy.url().should('contain', path)
|
||||
})
|
||||
|
||||
When('I start to write a new post with the title {string} beginning with:', (title, intro) => {
|
||||
cy.get('.post-add-button').click()
|
||||
cy.get('input[name="title"]').type(title)
|
||||
cy.get('.ProseMirror').type(intro)
|
||||
})
|
||||
|
||||
When('mention {string} in the text', (mention) => {
|
||||
cy.get('.ProseMirror').type(' @')
|
||||
cy.get('.suggestion-list__item').contains(mention).click()
|
||||
cy.debug()
|
||||
})
|
||||
|
||||
Then('the notification gets marked as read', () => {
|
||||
cy.get('.notification').first().should('have.class', 'read')
|
||||
})
|
||||
|
||||
Then('there are no notifications in the top menu', () => {
|
||||
cy.get('.notifications-menu').should('contain', '0')
|
||||
})
|
||||
|
||||
31
cypress/integration/notifications/Mentions.feature
Normal file
31
cypress/integration/notifications/Mentions.feature
Normal file
@ -0,0 +1,31 @@
|
||||
Feature: Notifications for a mentions
|
||||
As a user
|
||||
I want to be notified if sb. mentions me in a post or comment
|
||||
In order join conversations about or related to me
|
||||
|
||||
Background:
|
||||
Given we have the following user accounts:
|
||||
| name | slug | email | password |
|
||||
| Wolle aus Hamburg | wolle-aus-hamburg | wolle@example.org | 1234 |
|
||||
| Matt Rider | matt-rider | matt@example.org | 4321 |
|
||||
|
||||
Scenario: Mention another user, re-login as this user and see notifications
|
||||
Given I log in with the following credentials:
|
||||
| email | password |
|
||||
| wolle@example.org | 1234 |
|
||||
And I start to write a new post with the title "Hey Matt" beginning with:
|
||||
"""
|
||||
Big shout to our fellow contributor
|
||||
"""
|
||||
And mention "@matt-rider" in the text
|
||||
And I click on "Save"
|
||||
When I log out
|
||||
And I log in with the following credentials:
|
||||
| email | password |
|
||||
| matt@example.org | 4321 |
|
||||
And see 1 unread notifications in the top menu
|
||||
And open the notification menu and click on the first item
|
||||
Then I get to the post page of ".../hey-matt"
|
||||
And the notification gets marked as read
|
||||
But when I refresh the page
|
||||
Then there are no notifications in the top menu
|
||||
@ -46,7 +46,8 @@ Cypress.Commands.add('login', ({ email, password }) => {
|
||||
cy.get('button[name=submit]')
|
||||
.as('submitButton')
|
||||
.click()
|
||||
cy.location('pathname').should('eq', '/') // we're in!
|
||||
cy.get('.iziToast-message').should('contain', 'You are logged in!')
|
||||
cy.get('.iziToast-close').click()
|
||||
})
|
||||
|
||||
Cypress.Commands.add('logout', (email, password) => {
|
||||
|
||||
@ -4,11 +4,12 @@
|
||||
:image="post.image"
|
||||
:class="{'post-card': true, 'disabled-content': post.disabled}"
|
||||
>
|
||||
<a
|
||||
v-router-link
|
||||
<nuxt-link
|
||||
class="post-link"
|
||||
:href="href(post)"
|
||||
>{{ post.title }}</a>
|
||||
:to="{ name: 'post-id-slug', params: { id: post.id, slug: post.slug } }"
|
||||
>
|
||||
{{ post.title }}
|
||||
</nuxt-link>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<!-- TODO: replace editor content with tiptap render view -->
|
||||
<ds-space margin-bottom="large">
|
||||
@ -75,6 +76,7 @@
|
||||
import HcUser from '~/components/User'
|
||||
import ContentMenu from '~/components/ContentMenu'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
export default {
|
||||
name: 'HcPostCard',
|
||||
@ -89,26 +91,16 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
user: 'auth/user'
|
||||
}),
|
||||
excerpt() {
|
||||
// remove all links from excerpt to prevent issues with the serounding link
|
||||
let excerpt = this.post.contentExcerpt.replace(/<a.*>(.+)<\/a>/gim, '$1')
|
||||
// do not display content that is only linebreaks
|
||||
if (excerpt.replace(/<br>/gim, '').trim() === '') {
|
||||
excerpt = ''
|
||||
}
|
||||
|
||||
return excerpt
|
||||
return this.$filters.removeLinks(this.post.contentExcerpt)
|
||||
},
|
||||
isAuthor() {
|
||||
return this.$store.getters['auth/user'].id === this.post.author.id
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
href(post) {
|
||||
return this.$router.resolve({
|
||||
name: 'post-id-slug',
|
||||
params: { id: post.id, slug: post.slug }
|
||||
}).href
|
||||
const { author } = this.post
|
||||
if (!author) return false
|
||||
return this.user.id === this.post.author.id
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -130,6 +122,7 @@ export default {
|
||||
}
|
||||
|
||||
.post-link {
|
||||
margin: 15px;
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
62
webapp/components/PostCard/spec.js
Normal file
62
webapp/components/PostCard/spec.js
Normal file
@ -0,0 +1,62 @@
|
||||
import { config, mount, createLocalVue, RouterLinkStub } from '@vue/test-utils'
|
||||
import PostCard from '.'
|
||||
import Styleguide from '@human-connection/styleguide'
|
||||
import Vuex from 'vuex'
|
||||
import Filters from '~/plugins/vue-filters'
|
||||
|
||||
const localVue = createLocalVue()
|
||||
|
||||
localVue.use(Vuex)
|
||||
localVue.use(Styleguide)
|
||||
localVue.use(Filters)
|
||||
|
||||
config.stubs['no-ssr'] = '<span><slot /></span>'
|
||||
config.stubs['v-popover'] = '<span><slot /></span>'
|
||||
|
||||
describe('PostCard', () => {
|
||||
let wrapper
|
||||
let stubs
|
||||
let mocks
|
||||
let propsData
|
||||
let getters
|
||||
|
||||
beforeEach(() => {
|
||||
propsData = {}
|
||||
stubs = {
|
||||
NuxtLink: RouterLinkStub
|
||||
}
|
||||
mocks = {
|
||||
$t: jest.fn()
|
||||
}
|
||||
getters = {
|
||||
'auth/user': () => {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const Wrapper = () => {
|
||||
const store = new Vuex.Store({
|
||||
getters
|
||||
})
|
||||
return mount(PostCard, {
|
||||
stubs,
|
||||
mocks,
|
||||
propsData,
|
||||
store,
|
||||
localVue
|
||||
})
|
||||
}
|
||||
|
||||
describe('given a post', () => {
|
||||
beforeEach(() => {
|
||||
propsData.post = {
|
||||
title: "It's a title"
|
||||
}
|
||||
})
|
||||
|
||||
it('renders title', () => {
|
||||
expect(Wrapper().text()).toContain("It's a title")
|
||||
})
|
||||
})
|
||||
})
|
||||
71
webapp/components/notifications/Notification/index.vue
Normal file
71
webapp/components/notifications/Notification/index.vue
Normal file
@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<ds-space
|
||||
:class="{'notification': true, 'read': notification.read}"
|
||||
margin-bottom="x-small"
|
||||
>
|
||||
<no-ssr>
|
||||
<ds-space margin-bottom="x-small">
|
||||
<hc-user
|
||||
:user="post.author"
|
||||
:date-time="post.createdAt"
|
||||
:trunc="35"
|
||||
/>
|
||||
</ds-space>
|
||||
<ds-text color="soft">
|
||||
{{ $t("notifications.menu.mentioned") }}
|
||||
</ds-text>
|
||||
</no-ssr>
|
||||
<ds-space margin-bottom="x-small" />
|
||||
<nuxt-link
|
||||
class="notification-mention-post"
|
||||
:to="{ name: 'post-id-slug', params: { id: post.id, slug: post.slug } }"
|
||||
@click.native="$emit('read')"
|
||||
>
|
||||
<ds-space margin-bottom="x-small">
|
||||
<ds-card
|
||||
:header="post.title"
|
||||
:image="post.image"
|
||||
hover
|
||||
space="x-small"
|
||||
>
|
||||
<ds-space margin-bottom="x-small" />
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div v-html="excerpt" />
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</ds-card>
|
||||
</ds-space>
|
||||
</nuxt-link>
|
||||
</ds-space>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import HcUser from '~/components/User'
|
||||
|
||||
export default {
|
||||
name: 'Notification',
|
||||
components: {
|
||||
HcUser
|
||||
},
|
||||
props: {
|
||||
notification: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
excerpt() {
|
||||
return this.$filters.removeLinks(this.post.contentExcerpt)
|
||||
},
|
||||
post() {
|
||||
return this.notification.post || {}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.notification.read {
|
||||
opacity: 0.6; /* Real browsers */
|
||||
filter: alpha(opacity = 60); /* MSIE */
|
||||
}
|
||||
</style>
|
||||
64
webapp/components/notifications/Notification/spec.js
Normal file
64
webapp/components/notifications/Notification/spec.js
Normal file
@ -0,0 +1,64 @@
|
||||
import { config, mount, createLocalVue, RouterLinkStub } from '@vue/test-utils'
|
||||
import Notification from '.'
|
||||
import Styleguide from '@human-connection/styleguide'
|
||||
import Filters from '~/plugins/vue-filters'
|
||||
|
||||
const localVue = createLocalVue()
|
||||
|
||||
localVue.use(Styleguide)
|
||||
localVue.use(Filters)
|
||||
|
||||
config.stubs['no-ssr'] = '<span><slot /></span>'
|
||||
|
||||
describe('Notification', () => {
|
||||
let wrapper
|
||||
let stubs
|
||||
let mocks
|
||||
let propsData
|
||||
beforeEach(() => {
|
||||
propsData = {}
|
||||
mocks = {
|
||||
$t: jest.fn()
|
||||
}
|
||||
stubs = {
|
||||
NuxtLink: RouterLinkStub
|
||||
}
|
||||
})
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(Notification, {
|
||||
stubs,
|
||||
mocks,
|
||||
propsData,
|
||||
localVue
|
||||
})
|
||||
}
|
||||
|
||||
describe('given a notification', () => {
|
||||
beforeEach(() => {
|
||||
propsData.notification = {
|
||||
post: {
|
||||
title: "It's a title"
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('renders title', () => {
|
||||
expect(Wrapper().text()).toContain("It's a title")
|
||||
})
|
||||
|
||||
it('has no class "read"', () => {
|
||||
expect(Wrapper().classes()).not.toContain('read')
|
||||
})
|
||||
|
||||
describe('that is read', () => {
|
||||
beforeEach(() => {
|
||||
propsData.notification.read = true
|
||||
})
|
||||
|
||||
it('has class "read"', () => {
|
||||
expect(Wrapper().classes()).toContain('read')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
32
webapp/components/notifications/NotificationList/index.vue
Normal file
32
webapp/components/notifications/NotificationList/index.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div>
|
||||
<notification
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
:notification="notification"
|
||||
@read="markAsRead(notification.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Notification from '../Notification'
|
||||
|
||||
export default {
|
||||
name: 'NotificationList',
|
||||
components: {
|
||||
Notification
|
||||
},
|
||||
props: {
|
||||
notifications: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
markAsRead(notificationId) {
|
||||
this.$emit('markAsRead', notificationId)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
130
webapp/components/notifications/NotificationList/spec.js
Normal file
130
webapp/components/notifications/NotificationList/spec.js
Normal file
@ -0,0 +1,130 @@
|
||||
import {
|
||||
config,
|
||||
shallowMount,
|
||||
mount,
|
||||
createLocalVue,
|
||||
RouterLinkStub
|
||||
} from '@vue/test-utils'
|
||||
import NotificationList from '.'
|
||||
import Notification from '../Notification'
|
||||
import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
import Filters from '~/plugins/vue-filters'
|
||||
|
||||
import Styleguide from '@human-connection/styleguide'
|
||||
|
||||
const localVue = createLocalVue()
|
||||
|
||||
localVue.use(Vuex)
|
||||
localVue.use(Styleguide)
|
||||
localVue.use(Filters)
|
||||
localVue.filter('truncate', string => string)
|
||||
|
||||
config.stubs['no-ssr'] = '<span><slot /></span>'
|
||||
config.stubs['v-popover'] = '<span><slot /></span>'
|
||||
|
||||
describe('NotificationList.vue', () => {
|
||||
let wrapper
|
||||
let Wrapper
|
||||
let mocks
|
||||
let stubs
|
||||
let store
|
||||
let propsData
|
||||
|
||||
beforeEach(() => {
|
||||
store = new Vuex.Store({
|
||||
getters: {
|
||||
'auth/user': () => {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
})
|
||||
mocks = {
|
||||
$t: jest.fn()
|
||||
}
|
||||
stubs = {
|
||||
NuxtLink: RouterLinkStub
|
||||
}
|
||||
propsData = {
|
||||
notifications: [
|
||||
{
|
||||
id: 'notification-41',
|
||||
read: false,
|
||||
post: {
|
||||
id: 'post-1',
|
||||
title: 'some post title',
|
||||
contentExcerpt: 'this is a post content',
|
||||
author: {
|
||||
id: 'john-1',
|
||||
slug: 'john-doe',
|
||||
name: 'John Doe'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'notification-42',
|
||||
read: false,
|
||||
post: {
|
||||
id: 'post-2',
|
||||
title: 'another post title',
|
||||
contentExcerpt: 'this is yet another post content',
|
||||
author: {
|
||||
id: 'john-1',
|
||||
slug: 'john-doe',
|
||||
name: 'John Doe'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
describe('shallowMount', () => {
|
||||
const Wrapper = () => {
|
||||
return shallowMount(NotificationList, {
|
||||
propsData,
|
||||
mocks,
|
||||
store,
|
||||
localVue
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('renders Notification.vue for each notification of the user', () => {
|
||||
expect(wrapper.findAll(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
|
||||
.findAll('.notification-mention-post')
|
||||
.at(1)
|
||||
.trigger('click')
|
||||
})
|
||||
|
||||
it("emits 'markAsRead' with the notificationId", () => {
|
||||
expect(wrapper.emitted('markAsRead')).toBeTruthy()
|
||||
expect(wrapper.emitted('markAsRead')[0]).toEqual(['notification-42'])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
112
webapp/components/notifications/NotificationMenu/index.vue
Normal file
112
webapp/components/notifications/NotificationMenu/index.vue
Normal file
@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<ds-button
|
||||
v-if="totalNotifications <= 0"
|
||||
class="notifications-menu"
|
||||
disabled
|
||||
icon="bell"
|
||||
>
|
||||
{{ totalNotifications }}
|
||||
</ds-button>
|
||||
<dropdown
|
||||
v-else
|
||||
class="notifications-menu"
|
||||
>
|
||||
<template
|
||||
slot="default"
|
||||
slot-scope="{toggleMenu}"
|
||||
>
|
||||
<ds-button
|
||||
primary
|
||||
icon="bell"
|
||||
@click.prevent="toggleMenu"
|
||||
>
|
||||
{{ totalNotifications }}
|
||||
</ds-button>
|
||||
</template>
|
||||
<template
|
||||
slot="popover"
|
||||
>
|
||||
<div class="notifications-menu-popover">
|
||||
<notification-list
|
||||
:notifications="notifications"
|
||||
@markAsRead="markAsRead"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</dropdown>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NotificationList from '../NotificationList'
|
||||
import Dropdown from '~/components/Dropdown'
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
const MARK_AS_READ = gql(`
|
||||
mutation($id: ID!, $read: Boolean!) {
|
||||
UpdateNotification(id: $id, read: $read) {
|
||||
id
|
||||
read
|
||||
}
|
||||
}`)
|
||||
|
||||
const NOTIFICATIONS = gql(`{
|
||||
currentUser {
|
||||
id
|
||||
notifications(read: false, orderBy: createdAt_desc) {
|
||||
id read createdAt
|
||||
post {
|
||||
id createdAt disabled deleted title contentExcerpt slug
|
||||
author { id slug name disabled deleted }
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
export default {
|
||||
name: 'NotificationMenu',
|
||||
components: {
|
||||
NotificationList,
|
||||
Dropdown
|
||||
},
|
||||
computed: {
|
||||
totalNotifications() {
|
||||
return (this.notifications || []).length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async markAsRead(notificationId) {
|
||||
const variables = { id: notificationId, read: true }
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
mutation: MARK_AS_READ,
|
||||
variables
|
||||
})
|
||||
} catch (err) {
|
||||
throw new Error(err)
|
||||
}
|
||||
}
|
||||
},
|
||||
apollo: {
|
||||
notifications: {
|
||||
query: NOTIFICATIONS,
|
||||
update: data => {
|
||||
const {
|
||||
currentUser: { notifications }
|
||||
} = data
|
||||
return notifications
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.notifications-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.notifications-menu-popover {
|
||||
max-width: 500px;
|
||||
}
|
||||
</style>
|
||||
94
webapp/components/notifications/NotificationMenu/spec.js
Normal file
94
webapp/components/notifications/NotificationMenu/spec.js
Normal file
@ -0,0 +1,94 @@
|
||||
import { config, shallowMount, createLocalVue } from '@vue/test-utils'
|
||||
import NotificationMenu from '.'
|
||||
|
||||
import Styleguide from '@human-connection/styleguide'
|
||||
import Filters from '~/plugins/vue-filters'
|
||||
|
||||
const localVue = createLocalVue()
|
||||
|
||||
localVue.use(Styleguide)
|
||||
localVue.use(Filters)
|
||||
localVue.filter('truncate', string => string)
|
||||
|
||||
config.stubs['dropdown'] = '<span class="dropdown"><slot /></span>'
|
||||
|
||||
describe('NotificationMenu.vue', () => {
|
||||
let wrapper
|
||||
let Wrapper
|
||||
let mocks
|
||||
let data
|
||||
beforeEach(() => {
|
||||
mocks = {
|
||||
$t: jest.fn()
|
||||
}
|
||||
data = () => {
|
||||
return {
|
||||
notifications: []
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('shallowMount', () => {
|
||||
const Wrapper = () => {
|
||||
return shallowMount(NotificationMenu, {
|
||||
data,
|
||||
mocks,
|
||||
localVue
|
||||
})
|
||||
}
|
||||
|
||||
it('counter displays 0', () => {
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.find('ds-button-stub').text()).toEqual('0')
|
||||
})
|
||||
|
||||
it('no dropdown is rendered', () => {
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.contains('.dropdown')).toBe(false)
|
||||
})
|
||||
|
||||
describe('given some notifications', () => {
|
||||
beforeEach(() => {
|
||||
data = () => {
|
||||
return {
|
||||
notifications: [
|
||||
{
|
||||
id: 'notification-41',
|
||||
read: false,
|
||||
post: {
|
||||
id: 'post-1',
|
||||
title: 'some post title',
|
||||
contentExcerpt: 'this is a post content',
|
||||
author: {
|
||||
id: 'john-1',
|
||||
slug: 'john-doe',
|
||||
name: 'John Doe'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'notification-42',
|
||||
read: false,
|
||||
post: {
|
||||
id: 'post-2',
|
||||
title: 'another post title',
|
||||
contentExcerpt: 'this is yet another post content',
|
||||
author: {
|
||||
id: 'john-1',
|
||||
slug: 'john-doe',
|
||||
name: 'John Doe'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('displays the total number of notifications', () => {
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.find('ds-button-stub').text()).toEqual('2')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -10,7 +10,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import seo from '~/components/mixins/seo'
|
||||
import seo from '~/mixins/seo'
|
||||
|
||||
export default {
|
||||
mixins: [seo]
|
||||
|
||||
@ -31,6 +31,9 @@
|
||||
/>
|
||||
</no-ssr>
|
||||
<template v-if="isLoggedIn">
|
||||
<no-ssr>
|
||||
<notification-menu />
|
||||
</no-ssr>
|
||||
<no-ssr>
|
||||
<dropdown class="avatar-menu">
|
||||
<template
|
||||
@ -113,10 +116,11 @@
|
||||
<script>
|
||||
import { mapGetters, mapActions } from 'vuex'
|
||||
import LocaleSwitch from '~/components/LocaleSwitch'
|
||||
import Dropdown from '~/components/Dropdown'
|
||||
import SearchInput from '~/components/SearchInput.vue'
|
||||
import Modal from '~/components/Modal'
|
||||
import seo from '~/components/mixins/seo'
|
||||
import NotificationMenu from '~/components/notifications/NotificationMenu'
|
||||
import Dropdown from '~/components/Dropdown'
|
||||
import seo from '~/mixins/seo'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@ -124,7 +128,8 @@ export default {
|
||||
LocaleSwitch,
|
||||
SearchInput,
|
||||
Modal,
|
||||
LocaleSwitch
|
||||
LocaleSwitch,
|
||||
NotificationMenu
|
||||
},
|
||||
mixins: [seo],
|
||||
data() {
|
||||
|
||||
@ -18,6 +18,11 @@
|
||||
"commented": "Kommentiert",
|
||||
"socialMedia": "Wo sonst finde ich"
|
||||
},
|
||||
"notifications": {
|
||||
"menu": {
|
||||
"mentioned": "hat dich in einem Beitrag erwähnt"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Suchen",
|
||||
"hint": "Wonach suchst du?",
|
||||
|
||||
@ -18,6 +18,11 @@
|
||||
"commented": "Commented",
|
||||
"socialMedia": "Where else can I find"
|
||||
},
|
||||
"notifications": {
|
||||
"menu": {
|
||||
"mentioned": "has mentioned you in a post"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search",
|
||||
"hint": "What are you searching for?",
|
||||
|
||||
@ -34,7 +34,7 @@
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import uniqBy from 'lodash/uniqBy'
|
||||
import HcPostCard from '~/components/PostCard.vue'
|
||||
import HcPostCard from '~/components/PostCard'
|
||||
import HcLoadMore from '~/components/LoadMore.vue'
|
||||
|
||||
export default {
|
||||
|
||||
@ -56,7 +56,7 @@
|
||||
|
||||
<script>
|
||||
import gql from 'graphql-tag'
|
||||
import HcPostCard from '~/components/PostCard.vue'
|
||||
import HcPostCard from '~/components/PostCard'
|
||||
import HcEmpty from '~/components/Empty.vue'
|
||||
|
||||
export default {
|
||||
|
||||
@ -323,7 +323,7 @@
|
||||
import uniqBy from 'lodash/uniqBy'
|
||||
|
||||
import User from '~/components/User'
|
||||
import HcPostCard from '~/components/PostCard.vue'
|
||||
import HcPostCard from '~/components/PostCard'
|
||||
import HcFollowButton from '~/components/FollowButton.vue'
|
||||
import HcCountTo from '~/components/CountTo.vue'
|
||||
import HcBadges from '~/components/Badges.vue'
|
||||
|
||||
@ -6,7 +6,7 @@ import formatRelative from 'date-fns/formatRelative'
|
||||
import addSeconds from 'date-fns/addSeconds'
|
||||
import accounting from 'accounting'
|
||||
|
||||
export default ({ app }) => {
|
||||
export default ({ app = {} }) => {
|
||||
const locales = {
|
||||
en: enUS,
|
||||
de: de,
|
||||
@ -88,6 +88,17 @@ export default ({ app }) => {
|
||||
return index === 0 ? letter.toUpperCase() : letter.toLowerCase()
|
||||
})
|
||||
.replace(/\s+/g, '')
|
||||
},
|
||||
removeLinks: content => {
|
||||
if (!content) return ''
|
||||
// remove all links from excerpt to prevent issues with the surrounding link
|
||||
let excerpt = content.replace(/<a.*>(.+)<\/a>/gim, '$1')
|
||||
// do not display content that is only linebreaks
|
||||
if (excerpt.replace(/<br>/gim, '').trim() === '') {
|
||||
excerpt = ''
|
||||
}
|
||||
|
||||
return excerpt
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -74,21 +74,38 @@ export const actions = {
|
||||
data: { currentUser }
|
||||
} = await client.query({
|
||||
query: gql(`{
|
||||
currentUser {
|
||||
currentUser {
|
||||
id
|
||||
name
|
||||
slug
|
||||
email
|
||||
avatar
|
||||
role
|
||||
about
|
||||
locationName
|
||||
socialMedia {
|
||||
id
|
||||
name
|
||||
slug
|
||||
email
|
||||
avatar
|
||||
role
|
||||
about
|
||||
locationName
|
||||
socialMedia {
|
||||
id
|
||||
url
|
||||
url
|
||||
}
|
||||
notifications(read: false, orderBy: createdAt_desc) {
|
||||
id
|
||||
read
|
||||
createdAt
|
||||
post {
|
||||
author {
|
||||
id
|
||||
slug
|
||||
name
|
||||
disabled
|
||||
deleted
|
||||
}
|
||||
title
|
||||
contentExcerpt
|
||||
slug
|
||||
}
|
||||
}
|
||||
}`)
|
||||
}
|
||||
}`)
|
||||
})
|
||||
if (!currentUser) return dispatch('logout')
|
||||
commit('SET_USER', currentUser)
|
||||
|
||||
89
webapp/store/notifications.js
Normal file
89
webapp/store/notifications.js
Normal file
@ -0,0 +1,89 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export const state = () => {
|
||||
return {
|
||||
notifications: null,
|
||||
pending: false
|
||||
}
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
SET_NOTIFICATIONS(state, notifications) {
|
||||
state.notifications = notifications
|
||||
},
|
||||
SET_PENDING(state, pending) {
|
||||
state.pending = pending
|
||||
},
|
||||
UPDATE_NOTIFICATIONS(state, notification) {
|
||||
const notifications = state.notifications
|
||||
const toBeUpdated = notifications.find(n => {
|
||||
return n.id === notification.id
|
||||
})
|
||||
toBeUpdated = { ...toBeUpdated, ...notification }
|
||||
}
|
||||
}
|
||||
export const getters = {
|
||||
notifications(state) {
|
||||
return !!state.notifications
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
async init({ getters, commit }) {
|
||||
if (getters.notifications) return
|
||||
commit('SET_PENDING', true)
|
||||
const client = this.app.apolloProvider.defaultClient
|
||||
let notifications
|
||||
try {
|
||||
const {
|
||||
data: { currentUser }
|
||||
} = await client.query({
|
||||
query: gql(`{
|
||||
currentUser {
|
||||
id
|
||||
notifications(orderBy: createdAt_desc) {
|
||||
id
|
||||
read
|
||||
createdAt
|
||||
post {
|
||||
author {
|
||||
id
|
||||
slug
|
||||
name
|
||||
disabled
|
||||
deleted
|
||||
}
|
||||
title
|
||||
contentExcerpt
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
})
|
||||
notifications = currentUser.notifications
|
||||
console.log(notifications)
|
||||
commit('SET_NOTIFICATIONS', notifications)
|
||||
} finally {
|
||||
commit('SET_PENDING', false)
|
||||
}
|
||||
return notifications
|
||||
},
|
||||
|
||||
async markAsRead({ commit, rootGetters }, notificationId) {
|
||||
const client = this.app.apolloProvider.defaultClient
|
||||
const mutation = gql(`
|
||||
mutation($id: ID!, $read: Boolean!) {
|
||||
UpdateNotification(id: $id, read: $read) {
|
||||
id
|
||||
read
|
||||
}
|
||||
}
|
||||
`)
|
||||
const variables = { id: notificationId, read: true }
|
||||
const {
|
||||
data: { UpdateNotification }
|
||||
} = await client.mutate({ mutation, variables })
|
||||
commit('UPDATE_NOTIFICATIONS', UpdateNotification)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user