mirror of
https://github.com/IT4Change/Ocelot-Social.git
synced 2025-12-13 07:45:56 +00:00
Merge branch 'master' of github.com:Human-Connection/Human-Connection into 1395-hashtags-imported-with-not-allowed-chars
This commit is contained in:
commit
6892df5c7f
@ -61,7 +61,7 @@
|
|||||||
"dotenv": "~8.1.0",
|
"dotenv": "~8.1.0",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"faker": "Marak/faker.js#master",
|
"faker": "Marak/faker.js#master",
|
||||||
"graphql": "^14.5.0",
|
"graphql": "^14.5.3",
|
||||||
"graphql-custom-directives": "~0.2.14",
|
"graphql-custom-directives": "~0.2.14",
|
||||||
"graphql-iso-date": "~3.6.1",
|
"graphql-iso-date": "~3.6.1",
|
||||||
"graphql-middleware": "~3.0.5",
|
"graphql-middleware": "~3.0.5",
|
||||||
|
|||||||
@ -1,96 +0,0 @@
|
|||||||
import extractMentionedUsers from './notifications/extractMentionedUsers'
|
|
||||||
import extractHashtags from './hashtags/extractHashtags'
|
|
||||||
|
|
||||||
const notifyMentions = async (label, id, idsOfMentionedUsers, context) => {
|
|
||||||
if (!idsOfMentionedUsers.length) return
|
|
||||||
|
|
||||||
const session = context.driver.session()
|
|
||||||
const createdAt = new Date().toISOString()
|
|
||||||
let cypher
|
|
||||||
if (label === 'Post') {
|
|
||||||
cypher = `
|
|
||||||
MATCH (post: Post { id: $id })<-[:WROTE]-(author: User)
|
|
||||||
MATCH (user: User)
|
|
||||||
WHERE user.id in $idsOfMentionedUsers
|
|
||||||
AND NOT (user)<-[:BLOCKED]-(author)
|
|
||||||
CREATE (notification: Notification {id: apoc.create.uuid(), read: false, createdAt: $createdAt })
|
|
||||||
MERGE (post)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user)
|
|
||||||
`
|
|
||||||
} else {
|
|
||||||
cypher = `
|
|
||||||
MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(author: User)
|
|
||||||
MATCH (user: User)
|
|
||||||
WHERE user.id in $idsOfMentionedUsers
|
|
||||||
AND NOT (user)<-[:BLOCKED]-(author)
|
|
||||||
AND NOT (user)<-[:BLOCKED]-(postAuthor)
|
|
||||||
CREATE (notification: Notification {id: apoc.create.uuid(), read: false, createdAt: $createdAt })
|
|
||||||
MERGE (comment)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user)
|
|
||||||
`
|
|
||||||
}
|
|
||||||
await session.run(cypher, {
|
|
||||||
idsOfMentionedUsers,
|
|
||||||
label,
|
|
||||||
createdAt,
|
|
||||||
id,
|
|
||||||
})
|
|
||||||
session.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateHashtagsOfPost = async (postId, hashtags, context) => {
|
|
||||||
if (!hashtags.length) return
|
|
||||||
|
|
||||||
const session = context.driver.session()
|
|
||||||
// We need two Cypher statements, because the 'MATCH' in the 'cypherDeletePreviousRelations' statement
|
|
||||||
// functions as an 'if'. In case there is no previous relation, the rest of the commands are omitted
|
|
||||||
// and no new Hashtags and relations will be created.
|
|
||||||
const cypherDeletePreviousRelations = `
|
|
||||||
MATCH (p: Post { id: $postId })-[previousRelations: TAGGED]->(t: Tag)
|
|
||||||
DELETE previousRelations
|
|
||||||
RETURN p, t
|
|
||||||
`
|
|
||||||
const cypherCreateNewTagsAndRelations = `
|
|
||||||
MATCH (p: Post { id: $postId})
|
|
||||||
UNWIND $hashtags AS tagName
|
|
||||||
MERGE (t: Tag { id: tagName, disabled: false, deleted: false })
|
|
||||||
MERGE (p)-[:TAGGED]->(t)
|
|
||||||
RETURN p, t
|
|
||||||
`
|
|
||||||
await session.run(cypherDeletePreviousRelations, {
|
|
||||||
postId,
|
|
||||||
})
|
|
||||||
await session.run(cypherCreateNewTagsAndRelations, {
|
|
||||||
postId,
|
|
||||||
hashtags,
|
|
||||||
})
|
|
||||||
session.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => {
|
|
||||||
const idsOfMentionedUsers = extractMentionedUsers(args.content)
|
|
||||||
const hashtags = extractHashtags(args.content)
|
|
||||||
|
|
||||||
const post = await resolve(root, args, context, resolveInfo)
|
|
||||||
|
|
||||||
await notifyMentions('Post', post.id, idsOfMentionedUsers, context)
|
|
||||||
await updateHashtagsOfPost(post.id, hashtags, context)
|
|
||||||
|
|
||||||
return post
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleContentDataOfComment = async (resolve, root, args, context, resolveInfo) => {
|
|
||||||
const idsOfMentionedUsers = extractMentionedUsers(args.content)
|
|
||||||
const comment = await resolve(root, args, context, resolveInfo)
|
|
||||||
|
|
||||||
await notifyMentions('Comment', comment.id, idsOfMentionedUsers, context)
|
|
||||||
|
|
||||||
return comment
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
Mutation: {
|
|
||||||
CreatePost: handleContentDataOfPost,
|
|
||||||
UpdatePost: handleContentDataOfPost,
|
|
||||||
CreateComment: handleContentDataOfComment,
|
|
||||||
UpdateComment: handleContentDataOfComment,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@ -1,392 +0,0 @@
|
|||||||
import { gql } from '../../jest/helpers'
|
|
||||||
import Factory from '../../seed/factories'
|
|
||||||
import { createTestClient } from 'apollo-server-testing'
|
|
||||||
import { neode, getDriver } from '../../bootstrap/neo4j'
|
|
||||||
import createServer from '../../server'
|
|
||||||
|
|
||||||
let server
|
|
||||||
let query
|
|
||||||
let mutate
|
|
||||||
let user
|
|
||||||
let authenticatedUser
|
|
||||||
const factory = Factory()
|
|
||||||
const driver = getDriver()
|
|
||||||
const instance = neode()
|
|
||||||
const categoryIds = ['cat9']
|
|
||||||
const createPostMutation = gql`
|
|
||||||
mutation($id: ID, $title: String!, $content: String!, $categoryIds: [ID]!) {
|
|
||||||
CreatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
const updatePostMutation = gql`
|
|
||||||
mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID]!) {
|
|
||||||
UpdatePost(id: $id, content: $content, title: $title, categoryIds: $categoryIds) {
|
|
||||||
title
|
|
||||||
content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
const createServerResult = createServer({
|
|
||||||
context: () => {
|
|
||||||
return {
|
|
||||||
user: authenticatedUser,
|
|
||||||
neode: instance,
|
|
||||||
driver,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
server = createServerResult.server
|
|
||||||
const createTestClientResult = createTestClient(server)
|
|
||||||
query = createTestClientResult.query
|
|
||||||
mutate = createTestClientResult.mutate
|
|
||||||
})
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
user = await instance.create('User', {
|
|
||||||
id: 'you',
|
|
||||||
name: 'Al Capone',
|
|
||||||
slug: 'al-capone',
|
|
||||||
email: 'test@example.org',
|
|
||||||
password: '1234',
|
|
||||||
})
|
|
||||||
await instance.create('Category', {
|
|
||||||
id: 'cat9',
|
|
||||||
name: 'Democracy & Politics',
|
|
||||||
icon: 'university',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await factory.cleanDatabase()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('notifications', () => {
|
|
||||||
const notificationQuery = gql`
|
|
||||||
query($read: Boolean) {
|
|
||||||
currentUser {
|
|
||||||
notifications(read: $read, orderBy: createdAt_desc) {
|
|
||||||
read
|
|
||||||
post {
|
|
||||||
content
|
|
||||||
}
|
|
||||||
comment {
|
|
||||||
content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
describe('authenticated', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
authenticatedUser = user
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('given another user', () => {
|
|
||||||
let postAuthor
|
|
||||||
beforeEach(async () => {
|
|
||||||
postAuthor = await instance.create('User', {
|
|
||||||
email: 'post-author@example.org',
|
|
||||||
password: '1234',
|
|
||||||
id: 'postAuthor',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('who mentions me in a post', () => {
|
|
||||||
const title = 'Mentioning Al Capone'
|
|
||||||
const content =
|
|
||||||
'Hey <a class="mention" data-mention-id="you" href="/profile/you/al-capone">@al-capone</a> how do you do?'
|
|
||||||
|
|
||||||
const createPostAction = async () => {
|
|
||||||
authenticatedUser = await postAuthor.toJson()
|
|
||||||
await mutate({
|
|
||||||
mutation: createPostMutation,
|
|
||||||
variables: { id: 'p47', title, content, categoryIds },
|
|
||||||
})
|
|
||||||
authenticatedUser = await user.toJson()
|
|
||||||
}
|
|
||||||
|
|
||||||
it('sends you a notification', async () => {
|
|
||||||
await createPostAction()
|
|
||||||
const expectedContent =
|
|
||||||
'Hey <a class="mention" data-mention-id="you" href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do?'
|
|
||||||
const expected = expect.objectContaining({
|
|
||||||
data: {
|
|
||||||
currentUser: {
|
|
||||||
notifications: [
|
|
||||||
{
|
|
||||||
read: false,
|
|
||||||
post: {
|
|
||||||
content: expectedContent,
|
|
||||||
},
|
|
||||||
comment: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const { query } = createTestClient(server)
|
|
||||||
await expect(
|
|
||||||
query({
|
|
||||||
query: notificationQuery,
|
|
||||||
variables: {
|
|
||||||
read: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).resolves.toEqual(expected)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('who mentions me many times', () => {
|
|
||||||
const updatePostAction = async () => {
|
|
||||||
const updatedContent = `
|
|
||||||
One more mention to
|
|
||||||
<a data-mention-id="you" class="mention" href="/profile/you">
|
|
||||||
@al-capone
|
|
||||||
</a>
|
|
||||||
and again:
|
|
||||||
<a data-mention-id="you" class="mention" href="/profile/you">
|
|
||||||
@al-capone
|
|
||||||
</a>
|
|
||||||
and again
|
|
||||||
<a data-mention-id="you" class="mention" href="/profile/you">
|
|
||||||
@al-capone
|
|
||||||
</a>
|
|
||||||
`
|
|
||||||
authenticatedUser = await postAuthor.toJson()
|
|
||||||
await mutate({
|
|
||||||
mutation: updatePostMutation,
|
|
||||||
variables: {
|
|
||||||
id: 'p47',
|
|
||||||
title,
|
|
||||||
content: updatedContent,
|
|
||||||
categoryIds,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
authenticatedUser = await user.toJson()
|
|
||||||
}
|
|
||||||
|
|
||||||
it('creates exactly one more notification', async () => {
|
|
||||||
await createPostAction()
|
|
||||||
await updatePostAction()
|
|
||||||
const expectedContent =
|
|
||||||
'<br>One more mention to<br><a data-mention-id="you" class="mention" href="/profile/you" target="_blank"><br>@al-capone<br></a><br>and again:<br><a data-mention-id="you" class="mention" href="/profile/you" target="_blank"><br>@al-capone<br></a><br>and again<br><a data-mention-id="you" class="mention" href="/profile/you" target="_blank"><br>@al-capone<br></a><br>'
|
|
||||||
const expected = expect.objectContaining({
|
|
||||||
data: {
|
|
||||||
currentUser: {
|
|
||||||
notifications: [
|
|
||||||
{
|
|
||||||
read: false,
|
|
||||||
post: {
|
|
||||||
content: expectedContent,
|
|
||||||
},
|
|
||||||
comment: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
read: false,
|
|
||||||
post: {
|
|
||||||
content: expectedContent,
|
|
||||||
},
|
|
||||||
comment: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await expect(
|
|
||||||
query({
|
|
||||||
query: notificationQuery,
|
|
||||||
variables: {
|
|
||||||
read: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).resolves.toEqual(expected)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('but the author of the post blocked me', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
await postAuthor.relateTo(user, 'blocked')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sends no notification', async () => {
|
|
||||||
await createPostAction()
|
|
||||||
const expected = expect.objectContaining({
|
|
||||||
data: {
|
|
||||||
currentUser: {
|
|
||||||
notifications: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const { query } = createTestClient(server)
|
|
||||||
await expect(
|
|
||||||
query({
|
|
||||||
query: notificationQuery,
|
|
||||||
variables: {
|
|
||||||
read: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).resolves.toEqual(expected)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('but the author of the post blocked me and a mentioner mentions me in a comment', () => {
|
|
||||||
const createCommentOnPostAction = async () => {
|
|
||||||
await createPostAction()
|
|
||||||
const createCommentMutation = gql`
|
|
||||||
mutation($id: ID, $postId: ID!, $commentContent: String!) {
|
|
||||||
CreateComment(id: $id, postId: $postId, content: $commentContent) {
|
|
||||||
id
|
|
||||||
content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
authenticatedUser = await commentMentioner.toJson()
|
|
||||||
await mutate({
|
|
||||||
mutation: createCommentMutation,
|
|
||||||
variables: {
|
|
||||||
id: 'c47',
|
|
||||||
postId: 'p47',
|
|
||||||
commentContent:
|
|
||||||
'One mention of me with <a data-mention-id="you" class="mention" href="/profile/you" target="_blank">.',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
authenticatedUser = await user.toJson()
|
|
||||||
}
|
|
||||||
let commentMentioner
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await postAuthor.relateTo(user, 'blocked')
|
|
||||||
commentMentioner = await instance.create('User', {
|
|
||||||
id: 'mentioner',
|
|
||||||
name: 'Mr Mentioner',
|
|
||||||
slug: 'mr-mentioner',
|
|
||||||
email: 'mentioner@example.org',
|
|
||||||
password: '1234',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sends no notification', async () => {
|
|
||||||
await createCommentOnPostAction()
|
|
||||||
const expected = expect.objectContaining({
|
|
||||||
data: {
|
|
||||||
currentUser: {
|
|
||||||
notifications: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const { query } = createTestClient(server)
|
|
||||||
await expect(
|
|
||||||
query({
|
|
||||||
query: notificationQuery,
|
|
||||||
variables: {
|
|
||||||
read: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).resolves.toEqual(expected)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Hashtags', () => {
|
|
||||||
const id = 'p135'
|
|
||||||
const title = 'Two Hashtags'
|
|
||||||
const content =
|
|
||||||
'<p>Hey Dude, <a class="hashtag" href="/search/hashtag/Democracy">#Democracy</a> should work equal for everybody!? That seems to be the only way to have equal <a class="hashtag" href="/search/hashtag/Liberty">#Liberty</a> for everyone.</p>'
|
|
||||||
const postWithHastagsQuery = gql`
|
|
||||||
query($id: ID) {
|
|
||||||
Post(id: $id) {
|
|
||||||
tags {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
const postWithHastagsVariables = {
|
|
||||||
id,
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('authenticated', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
authenticatedUser = await user.toJson()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('create a Post with Hashtags', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
await mutate({
|
|
||||||
mutation: createPostMutation,
|
|
||||||
variables: {
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
content,
|
|
||||||
categoryIds,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('both Hashtags are created with the "id" set to their "name"', async () => {
|
|
||||||
const expected = [{ id: 'Democracy' }, { id: 'Liberty' }]
|
|
||||||
await expect(
|
|
||||||
query({
|
|
||||||
query: postWithHastagsQuery,
|
|
||||||
variables: postWithHastagsVariables,
|
|
||||||
}),
|
|
||||||
).resolves.toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
data: {
|
|
||||||
Post: [
|
|
||||||
{
|
|
||||||
tags: expect.arrayContaining(expected),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('afterwards update the Post by removing a Hashtag, leaving a Hashtag and add a Hashtag', () => {
|
|
||||||
// The already existing Hashtag has no class at this point.
|
|
||||||
const content =
|
|
||||||
'<p>Hey Dude, <a class="hashtag" href="/search/hashtag/Elections">#Elections</a> should work equal for everybody!? That seems to be the only way to have equal <a href="/search/hashtag/Liberty">#Liberty</a> for everyone.</p>'
|
|
||||||
|
|
||||||
it('only one previous Hashtag and the new Hashtag exists', async () => {
|
|
||||||
await mutate({
|
|
||||||
mutation: updatePostMutation,
|
|
||||||
variables: {
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
content,
|
|
||||||
categoryIds,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const expected = [{ id: 'Elections' }, { id: 'Liberty' }]
|
|
||||||
await expect(
|
|
||||||
query({
|
|
||||||
query: postWithHastagsQuery,
|
|
||||||
variables: postWithHastagsVariables,
|
|
||||||
}),
|
|
||||||
).resolves.toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
data: {
|
|
||||||
Post: [
|
|
||||||
{
|
|
||||||
tags: expect.arrayContaining(expected),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
49
backend/src/middleware/hashtags/hashtagsMiddleware.js
Normal file
49
backend/src/middleware/hashtags/hashtagsMiddleware.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import extractHashtags from '../hashtags/extractHashtags'
|
||||||
|
|
||||||
|
const updateHashtagsOfPost = async (postId, hashtags, context) => {
|
||||||
|
if (!hashtags.length) return
|
||||||
|
|
||||||
|
const session = context.driver.session()
|
||||||
|
// We need two Cypher statements, because the 'MATCH' in the 'cypherDeletePreviousRelations' statement
|
||||||
|
// functions as an 'if'. In case there is no previous relation, the rest of the commands are omitted
|
||||||
|
// and no new Hashtags and relations will be created.
|
||||||
|
const cypherDeletePreviousRelations = `
|
||||||
|
MATCH (p: Post { id: $postId })-[previousRelations: TAGGED]->(t: Tag)
|
||||||
|
DELETE previousRelations
|
||||||
|
RETURN p, t
|
||||||
|
`
|
||||||
|
const cypherCreateNewTagsAndRelations = `
|
||||||
|
MATCH (p: Post { id: $postId})
|
||||||
|
UNWIND $hashtags AS tagName
|
||||||
|
MERGE (t: Tag { id: tagName, disabled: false, deleted: false })
|
||||||
|
MERGE (p)-[:TAGGED]->(t)
|
||||||
|
RETURN p, t
|
||||||
|
`
|
||||||
|
await session.run(cypherDeletePreviousRelations, {
|
||||||
|
postId,
|
||||||
|
})
|
||||||
|
await session.run(cypherCreateNewTagsAndRelations, {
|
||||||
|
postId,
|
||||||
|
hashtags,
|
||||||
|
})
|
||||||
|
session.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => {
|
||||||
|
const hashtags = extractHashtags(args.content)
|
||||||
|
|
||||||
|
const post = await resolve(root, args, context, resolveInfo)
|
||||||
|
|
||||||
|
if (post) {
|
||||||
|
await updateHashtagsOfPost(post.id, hashtags, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
return post
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
Mutation: {
|
||||||
|
CreatePost: handleContentDataOfPost,
|
||||||
|
UpdatePost: handleContentDataOfPost,
|
||||||
|
},
|
||||||
|
}
|
||||||
176
backend/src/middleware/hashtags/hashtagsMiddleware.spec.js
Normal file
176
backend/src/middleware/hashtags/hashtagsMiddleware.spec.js
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import { gql } from '../../jest/helpers'
|
||||||
|
import Factory from '../../seed/factories'
|
||||||
|
import { createTestClient } from 'apollo-server-testing'
|
||||||
|
import { neode, getDriver } from '../../bootstrap/neo4j'
|
||||||
|
import createServer from '../../server'
|
||||||
|
|
||||||
|
let server
|
||||||
|
let query
|
||||||
|
let mutate
|
||||||
|
let hashtagingUser
|
||||||
|
let authenticatedUser
|
||||||
|
const factory = Factory()
|
||||||
|
const driver = getDriver()
|
||||||
|
const instance = neode()
|
||||||
|
const categoryIds = ['cat9']
|
||||||
|
const createPostMutation = gql`
|
||||||
|
mutation($id: ID, $title: String!, $postContent: String!, $categoryIds: [ID]!) {
|
||||||
|
CreatePost(id: $id, title: $title, content: $postContent, categoryIds: $categoryIds) {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const updatePostMutation = gql`
|
||||||
|
mutation($id: ID!, $title: String!, $postContent: String!, $categoryIds: [ID]!) {
|
||||||
|
UpdatePost(id: $id, content: $postContent, title: $title, categoryIds: $categoryIds) {
|
||||||
|
title
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
const createServerResult = createServer({
|
||||||
|
context: () => {
|
||||||
|
return {
|
||||||
|
user: authenticatedUser,
|
||||||
|
neode: instance,
|
||||||
|
driver,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
server = createServerResult.server
|
||||||
|
const createTestClientResult = createTestClient(server)
|
||||||
|
query = createTestClientResult.query
|
||||||
|
mutate = createTestClientResult.mutate
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
hashtagingUser = await instance.create('User', {
|
||||||
|
id: 'you',
|
||||||
|
name: 'Al Capone',
|
||||||
|
slug: 'al-capone',
|
||||||
|
email: 'test@example.org',
|
||||||
|
password: '1234',
|
||||||
|
})
|
||||||
|
await instance.create('Category', {
|
||||||
|
id: 'cat9',
|
||||||
|
name: 'Democracy & Politics',
|
||||||
|
icon: 'university',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await factory.cleanDatabase()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('hashtags', () => {
|
||||||
|
const id = 'p135'
|
||||||
|
const title = 'Two Hashtags'
|
||||||
|
const postContent =
|
||||||
|
'<p>Hey Dude, <a class="hashtag" href="/search/hashtag/Democracy">#Democracy</a> should work equal for everybody!? That seems to be the only way to have equal <a class="hashtag" href="/search/hashtag/Liberty">#Liberty</a> for everyone.</p>'
|
||||||
|
const postWithHastagsQuery = gql`
|
||||||
|
query($id: ID) {
|
||||||
|
Post(id: $id) {
|
||||||
|
tags {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const postWithHastagsVariables = {
|
||||||
|
id,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('authenticated', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
authenticatedUser = await hashtagingUser.toJson()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('create a Post with Hashtags', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await mutate({
|
||||||
|
mutation: createPostMutation,
|
||||||
|
variables: {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
postContent,
|
||||||
|
categoryIds,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('both hashtags are created with the "id" set to their "name"', async () => {
|
||||||
|
const expected = [
|
||||||
|
{
|
||||||
|
id: 'Democracy',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Liberty',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
await expect(
|
||||||
|
query({
|
||||||
|
query: postWithHastagsQuery,
|
||||||
|
variables: postWithHastagsVariables,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: {
|
||||||
|
Post: [
|
||||||
|
{
|
||||||
|
tags: expect.arrayContaining(expected),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('afterwards update the Post by removing a Hashtag, leaving a Hashtag and add a Hashtag', () => {
|
||||||
|
// The already existing Hashtag has no class at this point.
|
||||||
|
const postContent =
|
||||||
|
'<p>Hey Dude, <a class="hashtag" href="/search/hashtag/Elections">#Elections</a> should work equal for everybody!? That seems to be the only way to have equal <a href="/search/hashtag/Liberty">#Liberty</a> for everyone.</p>'
|
||||||
|
|
||||||
|
it('only one previous Hashtag and the new Hashtag exists', async () => {
|
||||||
|
await mutate({
|
||||||
|
mutation: updatePostMutation,
|
||||||
|
variables: {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
postContent,
|
||||||
|
categoryIds,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const expected = [
|
||||||
|
{
|
||||||
|
id: 'Elections',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Liberty',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
await expect(
|
||||||
|
query({
|
||||||
|
query: postWithHastagsQuery,
|
||||||
|
variables: postWithHastagsVariables,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: {
|
||||||
|
Post: [
|
||||||
|
{
|
||||||
|
tags: expect.arrayContaining(expected),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -12,7 +12,8 @@ import user from './userMiddleware'
|
|||||||
import includedFields from './includedFieldsMiddleware'
|
import includedFields from './includedFieldsMiddleware'
|
||||||
import orderBy from './orderByMiddleware'
|
import orderBy from './orderByMiddleware'
|
||||||
import validation from './validation/validationMiddleware'
|
import validation from './validation/validationMiddleware'
|
||||||
import handleContentData from './handleHtmlContent/handleContentData'
|
import notifications from './notifications/notificationsMiddleware'
|
||||||
|
import hashtags from './hashtags/hashtagsMiddleware'
|
||||||
import email from './email/emailMiddleware'
|
import email from './email/emailMiddleware'
|
||||||
import sentry from './sentryMiddleware'
|
import sentry from './sentryMiddleware'
|
||||||
|
|
||||||
@ -25,13 +26,16 @@ export default schema => {
|
|||||||
validation,
|
validation,
|
||||||
sluggify,
|
sluggify,
|
||||||
excerpt,
|
excerpt,
|
||||||
handleContentData,
|
notifications,
|
||||||
|
hashtags,
|
||||||
xss,
|
xss,
|
||||||
softDelete,
|
softDelete,
|
||||||
user,
|
user,
|
||||||
includedFields,
|
includedFields,
|
||||||
orderBy,
|
orderBy,
|
||||||
email: email({ isEnabled: CONFIG.SMTP_HOST && CONFIG.SMTP_PORT }),
|
email: email({
|
||||||
|
isEnabled: CONFIG.SMTP_HOST && CONFIG.SMTP_PORT,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
let order = [
|
let order = [
|
||||||
@ -43,7 +47,8 @@ export default schema => {
|
|||||||
'sluggify',
|
'sluggify',
|
||||||
'excerpt',
|
'excerpt',
|
||||||
'email',
|
'email',
|
||||||
'handleContentData',
|
'notifications',
|
||||||
|
'hashtags',
|
||||||
'xss',
|
'xss',
|
||||||
'softDelete',
|
'softDelete',
|
||||||
'user',
|
'user',
|
||||||
|
|||||||
122
backend/src/middleware/notifications/notificationsMiddleware.js
Normal file
122
backend/src/middleware/notifications/notificationsMiddleware.js
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import extractMentionedUsers from './mentions/extractMentionedUsers'
|
||||||
|
|
||||||
|
const notifyUsers = async (label, id, idsOfUsers, reason, context) => {
|
||||||
|
if (!idsOfUsers.length) return
|
||||||
|
|
||||||
|
// Checked here, because it does not go through GraphQL checks at all in this file.
|
||||||
|
const reasonsAllowed = ['mentioned_in_post', 'mentioned_in_comment', 'comment_on_post']
|
||||||
|
if (!reasonsAllowed.includes(reason)) {
|
||||||
|
throw new Error('Notification reason is not allowed!')
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(label === 'Post' && reason !== 'mentioned_in_post') ||
|
||||||
|
(label === 'Comment' && !['mentioned_in_comment', 'comment_on_post'].includes(reason))
|
||||||
|
) {
|
||||||
|
throw new Error('Notification does not fit the reason!')
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = context.driver.session()
|
||||||
|
const createdAt = new Date().toISOString()
|
||||||
|
let cypher
|
||||||
|
switch (reason) {
|
||||||
|
case 'mentioned_in_post': {
|
||||||
|
cypher = `
|
||||||
|
MATCH (post: Post { id: $id })<-[:WROTE]-(author: User)
|
||||||
|
MATCH (user: User)
|
||||||
|
WHERE user.id in $idsOfUsers
|
||||||
|
AND NOT (user)<-[:BLOCKED]-(author)
|
||||||
|
CREATE (notification: Notification {id: apoc.create.uuid(), read: false, reason: $reason, createdAt: $createdAt })
|
||||||
|
MERGE (post)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user)
|
||||||
|
`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'mentioned_in_comment': {
|
||||||
|
cypher = `
|
||||||
|
MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(author: User)
|
||||||
|
MATCH (user: User)
|
||||||
|
WHERE user.id in $idsOfUsers
|
||||||
|
AND NOT (user)<-[:BLOCKED]-(author)
|
||||||
|
AND NOT (user)<-[:BLOCKED]-(postAuthor)
|
||||||
|
CREATE (notification: Notification {id: apoc.create.uuid(), read: false, reason: $reason, createdAt: $createdAt })
|
||||||
|
MERGE (comment)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user)
|
||||||
|
`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'comment_on_post': {
|
||||||
|
cypher = `
|
||||||
|
MATCH (postAuthor: User)-[:WROTE]->(post: Post)<-[:COMMENTS]-(comment: Comment { id: $id })<-[:WROTE]-(author: User)
|
||||||
|
MATCH (user: User)
|
||||||
|
WHERE user.id in $idsOfUsers
|
||||||
|
AND NOT (user)<-[:BLOCKED]-(author)
|
||||||
|
AND NOT (author)<-[:BLOCKED]-(user)
|
||||||
|
CREATE (notification: Notification {id: apoc.create.uuid(), read: false, reason: $reason, createdAt: $createdAt })
|
||||||
|
MERGE (comment)-[:NOTIFIED]->(notification)-[:NOTIFIED]->(user)
|
||||||
|
`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await session.run(cypher, {
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
idsOfUsers,
|
||||||
|
reason,
|
||||||
|
createdAt,
|
||||||
|
})
|
||||||
|
session.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => {
|
||||||
|
const idsOfUsers = extractMentionedUsers(args.content)
|
||||||
|
|
||||||
|
const post = await resolve(root, args, context, resolveInfo)
|
||||||
|
|
||||||
|
if (post) {
|
||||||
|
await notifyUsers('Post', post.id, idsOfUsers, 'mentioned_in_post', context)
|
||||||
|
}
|
||||||
|
|
||||||
|
return post
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleContentDataOfComment = async (resolve, root, args, context, resolveInfo) => {
|
||||||
|
const idsOfUsers = extractMentionedUsers(args.content)
|
||||||
|
const comment = await resolve(root, args, context, resolveInfo)
|
||||||
|
|
||||||
|
if (comment) {
|
||||||
|
await notifyUsers('Comment', comment.id, idsOfUsers, 'mentioned_in_comment', context)
|
||||||
|
}
|
||||||
|
|
||||||
|
return comment
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateComment = async (resolve, root, args, context, resolveInfo) => {
|
||||||
|
const comment = await handleContentDataOfComment(resolve, root, args, context, resolveInfo)
|
||||||
|
|
||||||
|
if (comment) {
|
||||||
|
const session = context.driver.session()
|
||||||
|
const cypherFindUser = `
|
||||||
|
MATCH (user: User)-[:WROTE]->(:Post)<-[:COMMENTS]-(:Comment { id: $commentId })
|
||||||
|
RETURN user { .id }
|
||||||
|
`
|
||||||
|
const result = await session.run(cypherFindUser, {
|
||||||
|
commentId: comment.id,
|
||||||
|
})
|
||||||
|
session.close()
|
||||||
|
const [postAuthor] = await result.records.map(record => {
|
||||||
|
return record.get('user')
|
||||||
|
})
|
||||||
|
if (context.user.id !== postAuthor.id) {
|
||||||
|
await notifyUsers('Comment', comment.id, [postAuthor.id], 'comment_on_post', context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return comment
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
Mutation: {
|
||||||
|
CreatePost: handleContentDataOfPost,
|
||||||
|
UpdatePost: handleContentDataOfPost,
|
||||||
|
CreateComment: handleCreateComment,
|
||||||
|
UpdateComment: handleContentDataOfComment,
|
||||||
|
},
|
||||||
|
}
|
||||||
@ -0,0 +1,463 @@
|
|||||||
|
import { gql } from '../../jest/helpers'
|
||||||
|
import Factory from '../../seed/factories'
|
||||||
|
import { createTestClient } from 'apollo-server-testing'
|
||||||
|
import { neode, getDriver } from '../../bootstrap/neo4j'
|
||||||
|
import createServer from '../../server'
|
||||||
|
|
||||||
|
let server
|
||||||
|
let query
|
||||||
|
let mutate
|
||||||
|
let notifiedUser
|
||||||
|
let authenticatedUser
|
||||||
|
const factory = Factory()
|
||||||
|
const driver = getDriver()
|
||||||
|
const instance = neode()
|
||||||
|
const categoryIds = ['cat9']
|
||||||
|
const createPostMutation = gql`
|
||||||
|
mutation($id: ID, $title: String!, $postContent: String!, $categoryIds: [ID]!) {
|
||||||
|
CreatePost(id: $id, title: $title, content: $postContent, categoryIds: $categoryIds) {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const updatePostMutation = gql`
|
||||||
|
mutation($id: ID!, $title: String!, $postContent: String!, $categoryIds: [ID]!) {
|
||||||
|
UpdatePost(id: $id, content: $postContent, title: $title, categoryIds: $categoryIds) {
|
||||||
|
title
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const createCommentMutation = gql`
|
||||||
|
mutation($id: ID, $postId: ID!, $commentContent: String!) {
|
||||||
|
CreateComment(id: $id, postId: $postId, content: $commentContent) {
|
||||||
|
id
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
const createServerResult = createServer({
|
||||||
|
context: () => {
|
||||||
|
return {
|
||||||
|
user: authenticatedUser,
|
||||||
|
neode: instance,
|
||||||
|
driver,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
server = createServerResult.server
|
||||||
|
const createTestClientResult = createTestClient(server)
|
||||||
|
query = createTestClientResult.query
|
||||||
|
mutate = createTestClientResult.mutate
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
notifiedUser = await instance.create('User', {
|
||||||
|
id: 'you',
|
||||||
|
name: 'Al Capone',
|
||||||
|
slug: 'al-capone',
|
||||||
|
email: 'test@example.org',
|
||||||
|
password: '1234',
|
||||||
|
})
|
||||||
|
await instance.create('Category', {
|
||||||
|
id: 'cat9',
|
||||||
|
name: 'Democracy & Politics',
|
||||||
|
icon: 'university',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await factory.cleanDatabase()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('notifications', () => {
|
||||||
|
const notificationQuery = gql`
|
||||||
|
query($read: Boolean) {
|
||||||
|
currentUser {
|
||||||
|
notifications(read: $read, orderBy: createdAt_desc) {
|
||||||
|
read
|
||||||
|
reason
|
||||||
|
post {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
comment {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
describe('authenticated', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
authenticatedUser = await notifiedUser.toJson()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('given another user', () => {
|
||||||
|
let title
|
||||||
|
let postContent
|
||||||
|
let postAuthor
|
||||||
|
const createPostAction = async () => {
|
||||||
|
authenticatedUser = await postAuthor.toJson()
|
||||||
|
await mutate({
|
||||||
|
mutation: createPostMutation,
|
||||||
|
variables: {
|
||||||
|
id: 'p47',
|
||||||
|
title,
|
||||||
|
postContent,
|
||||||
|
categoryIds,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
authenticatedUser = await notifiedUser.toJson()
|
||||||
|
}
|
||||||
|
|
||||||
|
let commentContent
|
||||||
|
let commentAuthor
|
||||||
|
const createCommentOnPostAction = async () => {
|
||||||
|
await createPostAction()
|
||||||
|
authenticatedUser = await commentAuthor.toJson()
|
||||||
|
await mutate({
|
||||||
|
mutation: createCommentMutation,
|
||||||
|
variables: {
|
||||||
|
id: 'c47',
|
||||||
|
postId: 'p47',
|
||||||
|
commentContent,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
authenticatedUser = await notifiedUser.toJson()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('comments on my post', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
title = 'My post'
|
||||||
|
postContent = 'My post content.'
|
||||||
|
postAuthor = notifiedUser
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('commenter is not me', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
commentContent = 'Commenters comment.'
|
||||||
|
commentAuthor = await instance.create('User', {
|
||||||
|
id: 'commentAuthor',
|
||||||
|
name: 'Mrs Comment',
|
||||||
|
slug: 'mrs-comment',
|
||||||
|
email: 'commentauthor@example.org',
|
||||||
|
password: '1234',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends me a notification', async () => {
|
||||||
|
await createCommentOnPostAction()
|
||||||
|
const expected = expect.objectContaining({
|
||||||
|
data: {
|
||||||
|
currentUser: {
|
||||||
|
notifications: [
|
||||||
|
{
|
||||||
|
read: false,
|
||||||
|
reason: 'comment_on_post',
|
||||||
|
post: null,
|
||||||
|
comment: {
|
||||||
|
content: commentContent,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const { query } = createTestClient(server)
|
||||||
|
await expect(
|
||||||
|
query({
|
||||||
|
query: notificationQuery,
|
||||||
|
variables: {
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends me no notification if I have blocked the comment author', async () => {
|
||||||
|
await notifiedUser.relateTo(commentAuthor, 'blocked')
|
||||||
|
await createCommentOnPostAction()
|
||||||
|
const expected = expect.objectContaining({
|
||||||
|
data: {
|
||||||
|
currentUser: {
|
||||||
|
notifications: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const { query } = createTestClient(server)
|
||||||
|
await expect(
|
||||||
|
query({
|
||||||
|
query: notificationQuery,
|
||||||
|
variables: {
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('commenter is me', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
commentContent = 'My comment.'
|
||||||
|
commentAuthor = notifiedUser
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends me no notification', async () => {
|
||||||
|
await notifiedUser.relateTo(commentAuthor, 'blocked')
|
||||||
|
await createCommentOnPostAction()
|
||||||
|
const expected = expect.objectContaining({
|
||||||
|
data: {
|
||||||
|
currentUser: {
|
||||||
|
notifications: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const { query } = createTestClient(server)
|
||||||
|
await expect(
|
||||||
|
query({
|
||||||
|
query: notificationQuery,
|
||||||
|
variables: {
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
postAuthor = await instance.create('User', {
|
||||||
|
id: 'postAuthor',
|
||||||
|
name: 'Mrs Post',
|
||||||
|
slug: 'mrs-post',
|
||||||
|
email: 'post-author@example.org',
|
||||||
|
password: '1234',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mentions me in a post', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
title = 'Mentioning Al Capone'
|
||||||
|
postContent =
|
||||||
|
'Hey <a class="mention" data-mention-id="you" href="/profile/you/al-capone">@al-capone</a> how do you do?'
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends me a notification', async () => {
|
||||||
|
await createPostAction()
|
||||||
|
const expectedContent =
|
||||||
|
'Hey <a class="mention" data-mention-id="you" href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do?'
|
||||||
|
const expected = expect.objectContaining({
|
||||||
|
data: {
|
||||||
|
currentUser: {
|
||||||
|
notifications: [
|
||||||
|
{
|
||||||
|
read: false,
|
||||||
|
reason: 'mentioned_in_post',
|
||||||
|
post: {
|
||||||
|
content: expectedContent,
|
||||||
|
},
|
||||||
|
comment: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const { query } = createTestClient(server)
|
||||||
|
await expect(
|
||||||
|
query({
|
||||||
|
query: notificationQuery,
|
||||||
|
variables: {
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('many times', () => {
|
||||||
|
const updatePostAction = async () => {
|
||||||
|
const updatedContent = `
|
||||||
|
One more mention to
|
||||||
|
<a data-mention-id="you" class="mention" href="/profile/you">
|
||||||
|
@al-capone
|
||||||
|
</a>
|
||||||
|
and again:
|
||||||
|
<a data-mention-id="you" class="mention" href="/profile/you">
|
||||||
|
@al-capone
|
||||||
|
</a>
|
||||||
|
and again
|
||||||
|
<a data-mention-id="you" class="mention" href="/profile/you">
|
||||||
|
@al-capone
|
||||||
|
</a>
|
||||||
|
`
|
||||||
|
authenticatedUser = await postAuthor.toJson()
|
||||||
|
await mutate({
|
||||||
|
mutation: updatePostMutation,
|
||||||
|
variables: {
|
||||||
|
id: 'p47',
|
||||||
|
title,
|
||||||
|
postContent: updatedContent,
|
||||||
|
categoryIds,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
authenticatedUser = await notifiedUser.toJson()
|
||||||
|
}
|
||||||
|
|
||||||
|
it('creates exactly one more notification', async () => {
|
||||||
|
await createPostAction()
|
||||||
|
await updatePostAction()
|
||||||
|
const expectedContent =
|
||||||
|
'<br>One more mention to<br><a data-mention-id="you" class="mention" href="/profile/you" target="_blank"><br>@al-capone<br></a><br>and again:<br><a data-mention-id="you" class="mention" href="/profile/you" target="_blank"><br>@al-capone<br></a><br>and again<br><a data-mention-id="you" class="mention" href="/profile/you" target="_blank"><br>@al-capone<br></a><br>'
|
||||||
|
const expected = expect.objectContaining({
|
||||||
|
data: {
|
||||||
|
currentUser: {
|
||||||
|
notifications: [
|
||||||
|
{
|
||||||
|
read: false,
|
||||||
|
reason: 'mentioned_in_post',
|
||||||
|
post: {
|
||||||
|
content: expectedContent,
|
||||||
|
},
|
||||||
|
comment: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
read: false,
|
||||||
|
reason: 'mentioned_in_post',
|
||||||
|
post: {
|
||||||
|
content: expectedContent,
|
||||||
|
},
|
||||||
|
comment: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
query({
|
||||||
|
query: notificationQuery,
|
||||||
|
variables: {
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('but the author of the post blocked me', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await postAuthor.relateTo(notifiedUser, 'blocked')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends no notification', async () => {
|
||||||
|
await createPostAction()
|
||||||
|
const expected = expect.objectContaining({
|
||||||
|
data: {
|
||||||
|
currentUser: {
|
||||||
|
notifications: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const { query } = createTestClient(server)
|
||||||
|
await expect(
|
||||||
|
query({
|
||||||
|
query: notificationQuery,
|
||||||
|
variables: {
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('mentions me in a comment', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
title = 'Post where I get mentioned in a comment'
|
||||||
|
postContent = 'Content of post where I get mentioned in a comment.'
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('I am not blocked at all', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
commentContent =
|
||||||
|
'One mention about me with <a data-mention-id="you" class="mention" href="/profile/you" target="_blank">@al-capone</a>.'
|
||||||
|
commentAuthor = await instance.create('User', {
|
||||||
|
id: 'commentAuthor',
|
||||||
|
name: 'Mrs Comment',
|
||||||
|
slug: 'mrs-comment',
|
||||||
|
email: 'comment-author@example.org',
|
||||||
|
password: '1234',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends a notification', async () => {
|
||||||
|
await createCommentOnPostAction()
|
||||||
|
const expected = expect.objectContaining({
|
||||||
|
data: {
|
||||||
|
currentUser: {
|
||||||
|
notifications: [
|
||||||
|
{
|
||||||
|
read: false,
|
||||||
|
reason: 'mentioned_in_comment',
|
||||||
|
post: null,
|
||||||
|
comment: {
|
||||||
|
content: commentContent,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const { query } = createTestClient(server)
|
||||||
|
await expect(
|
||||||
|
query({
|
||||||
|
query: notificationQuery,
|
||||||
|
variables: {
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('but the author of the post blocked me', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await postAuthor.relateTo(notifiedUser, 'blocked')
|
||||||
|
commentContent =
|
||||||
|
'One mention about me with <a data-mention-id="you" class="mention" href="/profile/you" target="_blank">@al-capone</a>.'
|
||||||
|
commentAuthor = await instance.create('User', {
|
||||||
|
id: 'commentAuthor',
|
||||||
|
name: 'Mrs Comment',
|
||||||
|
slug: 'mrs-comment',
|
||||||
|
email: 'comment-author@example.org',
|
||||||
|
password: '1234',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends no notification', async () => {
|
||||||
|
await createCommentOnPostAction()
|
||||||
|
const expected = expect.objectContaining({
|
||||||
|
data: {
|
||||||
|
currentUser: {
|
||||||
|
notifications: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const { query } = createTestClient(server)
|
||||||
|
await expect(
|
||||||
|
query({
|
||||||
|
query: notificationQuery,
|
||||||
|
variables: {
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,9 +1,26 @@
|
|||||||
import uuid from 'uuid/v4'
|
import uuid from 'uuid/v4'
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
id: { type: 'uuid', primary: true, default: uuid },
|
id: {
|
||||||
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
|
type: 'uuid',
|
||||||
read: { type: 'boolean', default: false },
|
primary: true,
|
||||||
|
default: uuid,
|
||||||
|
},
|
||||||
|
read: {
|
||||||
|
type: 'boolean',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
reason: {
|
||||||
|
type: 'string',
|
||||||
|
valid: ['mentioned_in_post', 'mentioned_in_comment', 'comment_on_post'],
|
||||||
|
invalid: [null],
|
||||||
|
default: 'mentioned_in_post',
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: 'string',
|
||||||
|
isoDate: true,
|
||||||
|
default: () => new Date().toISOString(),
|
||||||
|
},
|
||||||
user: {
|
user: {
|
||||||
type: 'relationship',
|
type: 'relationship',
|
||||||
relationship: 'NOTIFIED',
|
relationship: 'NOTIFIED',
|
||||||
|
|||||||
@ -69,23 +69,29 @@ describe('currentUser notifications', () => {
|
|||||||
factory.create('User', neighborParams),
|
factory.create('User', neighborParams),
|
||||||
factory.create('Notification', {
|
factory.create('Notification', {
|
||||||
id: 'post-mention-not-for-you',
|
id: 'post-mention-not-for-you',
|
||||||
|
reason: 'mentioned_in_post',
|
||||||
}),
|
}),
|
||||||
factory.create('Notification', {
|
factory.create('Notification', {
|
||||||
id: 'post-mention-already-seen',
|
id: 'post-mention-already-seen',
|
||||||
read: true,
|
read: true,
|
||||||
|
reason: 'mentioned_in_post',
|
||||||
}),
|
}),
|
||||||
factory.create('Notification', {
|
factory.create('Notification', {
|
||||||
id: 'post-mention-unseen',
|
id: 'post-mention-unseen',
|
||||||
|
reason: 'mentioned_in_post',
|
||||||
}),
|
}),
|
||||||
factory.create('Notification', {
|
factory.create('Notification', {
|
||||||
id: 'comment-mention-not-for-you',
|
id: 'comment-mention-not-for-you',
|
||||||
|
reason: 'mentioned_in_comment',
|
||||||
}),
|
}),
|
||||||
factory.create('Notification', {
|
factory.create('Notification', {
|
||||||
id: 'comment-mention-already-seen',
|
id: 'comment-mention-already-seen',
|
||||||
read: true,
|
read: true,
|
||||||
|
reason: 'mentioned_in_comment',
|
||||||
}),
|
}),
|
||||||
factory.create('Notification', {
|
factory.create('Notification', {
|
||||||
id: 'comment-mention-unseen',
|
id: 'comment-mention-unseen',
|
||||||
|
reason: 'mentioned_in_comment',
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
await factory.authenticateAs(neighborParams)
|
await factory.authenticateAs(neighborParams)
|
||||||
@ -287,9 +293,11 @@ describe('UpdateNotification', () => {
|
|||||||
factory.create('User', mentionedParams),
|
factory.create('User', mentionedParams),
|
||||||
factory.create('Notification', {
|
factory.create('Notification', {
|
||||||
id: 'post-mention-to-be-updated',
|
id: 'post-mention-to-be-updated',
|
||||||
|
reason: 'mentioned_in_post',
|
||||||
}),
|
}),
|
||||||
factory.create('Notification', {
|
factory.create('Notification', {
|
||||||
id: 'comment-mention-to-be-updated',
|
id: 'comment-mention-to-be-updated',
|
||||||
|
reason: 'mentioned_in_comment',
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
await factory.authenticateAs(userParams)
|
await factory.authenticateAs(userParams)
|
||||||
|
|||||||
5
backend/src/schema/types/enum/ReasonNotification.gql
Normal file
5
backend/src/schema/types/enum/ReasonNotification.gql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
enum ReasonNotification {
|
||||||
|
mentioned_in_post
|
||||||
|
mentioned_in_comment
|
||||||
|
comment_on_post
|
||||||
|
}
|
||||||
@ -1,8 +1,9 @@
|
|||||||
type Notification {
|
type Notification {
|
||||||
id: ID!
|
id: ID!
|
||||||
read: Boolean
|
read: Boolean
|
||||||
|
reason: ReasonNotification
|
||||||
|
createdAt: String
|
||||||
user: User @relation(name: "NOTIFIED", direction: "OUT")
|
user: User @relation(name: "NOTIFIED", direction: "OUT")
|
||||||
post: Post @relation(name: "NOTIFIED", direction: "IN")
|
post: Post @relation(name: "NOTIFIED", direction: "IN")
|
||||||
comment: Comment @relation(name: "NOTIFIED", direction: "IN")
|
comment: Comment @relation(name: "NOTIFIED", direction: "IN")
|
||||||
createdAt: String
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,15 +62,26 @@ export default function Factory(options = {}) {
|
|||||||
lastResponse: null,
|
lastResponse: null,
|
||||||
neodeInstance,
|
neodeInstance,
|
||||||
async authenticateAs({ email, password }) {
|
async authenticateAs({ email, password }) {
|
||||||
const headers = await authenticatedHeaders({ email, password }, seedServerHost)
|
const headers = await authenticatedHeaders(
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
seedServerHost,
|
||||||
|
)
|
||||||
this.lastResponse = headers
|
this.lastResponse = headers
|
||||||
this.graphQLClient = new GraphQLClient(seedServerHost, { headers })
|
this.graphQLClient = new GraphQLClient(seedServerHost, {
|
||||||
|
headers,
|
||||||
|
})
|
||||||
return this
|
return this
|
||||||
},
|
},
|
||||||
async create(node, args = {}) {
|
async create(node, args = {}) {
|
||||||
const { factory, mutation, variables } = this.factories[node](args)
|
const { factory, mutation, variables } = this.factories[node](args)
|
||||||
if (factory) {
|
if (factory) {
|
||||||
this.lastResponse = await factory({ args, neodeInstance })
|
this.lastResponse = await factory({
|
||||||
|
args,
|
||||||
|
neodeInstance,
|
||||||
|
})
|
||||||
return this.lastResponse
|
return this.lastResponse
|
||||||
} else {
|
} else {
|
||||||
this.lastResponse = await this.graphQLClient.request(mutation, variables)
|
this.lastResponse = await this.graphQLClient.request(mutation, variables)
|
||||||
@ -121,11 +132,15 @@ export default function Factory(options = {}) {
|
|||||||
},
|
},
|
||||||
async invite({ email }) {
|
async invite({ email }) {
|
||||||
const mutation = ` mutation($email: String!) { invite( email: $email) } `
|
const mutation = ` mutation($email: String!) { invite( email: $email) } `
|
||||||
this.lastResponse = await this.graphQLClient.request(mutation, { email })
|
this.lastResponse = await this.graphQLClient.request(mutation, {
|
||||||
|
email,
|
||||||
|
})
|
||||||
return this
|
return this
|
||||||
},
|
},
|
||||||
async cleanDatabase() {
|
async cleanDatabase() {
|
||||||
this.lastResponse = await cleanDatabase({ driver: this.neo4jDriver })
|
this.lastResponse = await cleanDatabase({
|
||||||
|
driver: this.neo4jDriver,
|
||||||
|
})
|
||||||
return this
|
return this
|
||||||
},
|
},
|
||||||
async emote({ to, data }) {
|
async emote({ to, data }) {
|
||||||
|
|||||||
@ -4193,10 +4193,10 @@ graphql-upload@^8.0.2:
|
|||||||
http-errors "^1.7.2"
|
http-errors "^1.7.2"
|
||||||
object-path "^0.11.4"
|
object-path "^0.11.4"
|
||||||
|
|
||||||
graphql@^14.2.1, graphql@^14.5.0:
|
graphql@^14.2.1, graphql@^14.5.3:
|
||||||
version "14.5.0"
|
version "14.5.3"
|
||||||
resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.5.0.tgz#4801e6460942c9c591944617f6dd224a9e531520"
|
resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.5.3.tgz#e025851cc413e153220f4edbbb25d49f55104fa0"
|
||||||
integrity sha512-wnGcTD181L2xPnIwHHjx/moV4ulxA2Kms9zcUY+B/SIrK+2N+iOC6WNgnR2zVTmg1Z8P+CZq5KXibTnatg3WUw==
|
integrity sha512-W8A8nt9BsMg0ZK2qA3DJIVU6muWhxZRYLTmc+5XGwzWzVdUdPVlAAg5hTBjiTISEnzsKL/onasu6vl3kgGTbYg==
|
||||||
dependencies:
|
dependencies:
|
||||||
iterall "^1.2.2"
|
iterall "^1.2.2"
|
||||||
|
|
||||||
|
|||||||
@ -25,13 +25,20 @@
|
|||||||
- name: nitro-neo4j
|
- name: nitro-neo4j
|
||||||
image: humanconnection/neo4j:latest
|
image: humanconnection/neo4j:latest
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "1G"
|
||||||
|
limits:
|
||||||
|
memory: "2G"
|
||||||
env:
|
env:
|
||||||
- name: NEO4J_apoc_import_file_enabled
|
- name: NEO4J_apoc_import_file_enabled
|
||||||
value: "true"
|
value: "true"
|
||||||
- name: NEO4J_dbms_memory_pagecache_size
|
- name: NEO4J_dbms_memory_pagecache_size
|
||||||
value: 1G
|
value: "490M"
|
||||||
- name: NEO4J_dbms_memory_heap_max__size
|
- name: NEO4J_dbms_memory_heap_max__size
|
||||||
value: 1G
|
value: "500M"
|
||||||
|
- name: NEO4J_dbms_memory_heap_initial__size
|
||||||
|
value: "500M"
|
||||||
- name: NEO4J_dbms_security_procedures_unrestricted
|
- name: NEO4J_dbms_security_procedures_unrestricted
|
||||||
value: "algo.*,apoc.*"
|
value: "algo.*,apoc.*"
|
||||||
envFrom:
|
envFrom:
|
||||||
|
|||||||
@ -34,4 +34,4 @@
|
|||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"slug": "^1.1.0"
|
"slug": "^1.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,7 @@ const localVue = createLocalVue()
|
|||||||
localVue.use(Vuex)
|
localVue.use(Vuex)
|
||||||
localVue.use(Styleguide)
|
localVue.use(Styleguide)
|
||||||
|
|
||||||
config.stubs['no-ssr'] = '<span><slot /></span>'
|
config.stubs['client-only'] = '<span><slot /></span>'
|
||||||
|
|
||||||
describe('Comment.vue', () => {
|
describe('Comment.vue', () => {
|
||||||
let propsData
|
let propsData
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
<hc-user :user="author" :date-time="comment.createdAt" />
|
<hc-user :user="author" :date-time="comment.createdAt" />
|
||||||
</ds-space>
|
</ds-space>
|
||||||
<!-- Content Menu (can open Modals) -->
|
<!-- Content Menu (can open Modals) -->
|
||||||
<no-ssr>
|
<client-only>
|
||||||
<content-menu
|
<content-menu
|
||||||
placement="bottom-end"
|
placement="bottom-end"
|
||||||
resource-type="comment"
|
resource-type="comment"
|
||||||
@ -25,7 +25,7 @@
|
|||||||
:is-owner="isAuthor(author.id)"
|
:is-owner="isAuthor(author.id)"
|
||||||
@showEditCommentMenu="editCommentMenu"
|
@showEditCommentMenu="editCommentMenu"
|
||||||
/>
|
/>
|
||||||
</no-ssr>
|
</client-only>
|
||||||
|
|
||||||
<ds-space margin-bottom="small" />
|
<ds-space margin-bottom="small" />
|
||||||
<div v-if="openEditCommentMenu">
|
<div v-if="openEditCommentMenu">
|
||||||
|
|||||||
@ -14,7 +14,7 @@ localVue.filter('truncate', string => string)
|
|||||||
|
|
||||||
config.stubs['v-popover'] = '<span><slot /></span>'
|
config.stubs['v-popover'] = '<span><slot /></span>'
|
||||||
config.stubs['nuxt-link'] = '<span><slot /></span>'
|
config.stubs['nuxt-link'] = '<span><slot /></span>'
|
||||||
config.stubs['no-ssr'] = '<span><slot /></span>'
|
config.stubs['client-only'] = '<span><slot /></span>'
|
||||||
|
|
||||||
describe('CommentList.vue', () => {
|
describe('CommentList.vue', () => {
|
||||||
let mocks
|
let mocks
|
||||||
|
|||||||
@ -16,7 +16,7 @@ localVue.use(Vuex)
|
|||||||
localVue.use(Styleguide)
|
localVue.use(Styleguide)
|
||||||
localVue.use(Filters)
|
localVue.use(Filters)
|
||||||
|
|
||||||
config.stubs['no-ssr'] = '<span><slot /></span>'
|
config.stubs['client-only'] = '<span><slot /></span>'
|
||||||
config.stubs['nuxt-link'] = '<span><slot /></span>'
|
config.stubs['nuxt-link'] = '<span><slot /></span>'
|
||||||
config.stubs['v-popover'] = '<span><slot /></span>'
|
config.stubs['v-popover'] = '<span><slot /></span>'
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
<ds-space />
|
<ds-space />
|
||||||
<ds-input model="title" class="post-title" placeholder="Title" name="title" autofocus />
|
<ds-input model="title" class="post-title" placeholder="Title" name="title" autofocus />
|
||||||
<small class="smallTag">{{ form.title.length }}/{{ formSchema.title.max }}</small>
|
<small class="smallTag">{{ form.title.length }}/{{ formSchema.title.max }}</small>
|
||||||
<no-ssr>
|
<client-only>
|
||||||
<hc-editor
|
<hc-editor
|
||||||
:users="users"
|
:users="users"
|
||||||
:value="form.content"
|
:value="form.content"
|
||||||
@ -22,7 +22,7 @@
|
|||||||
@input="updateEditorContent"
|
@input="updateEditorContent"
|
||||||
/>
|
/>
|
||||||
<small class="smallTag">{{ form.contentLength }}/{{ contentMax }}</small>
|
<small class="smallTag">{{ form.contentLength }}/{{ contentMax }}</small>
|
||||||
</no-ssr>
|
</client-only>
|
||||||
<ds-space margin-bottom="xxx-large" />
|
<ds-space margin-bottom="xxx-large" />
|
||||||
<hc-categories-select
|
<hc-categories-select
|
||||||
model="categoryIds"
|
model="categoryIds"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<span>
|
<span>
|
||||||
<no-ssr placeholder="0" tag="span">
|
<client-only placeholder="0" tag="span">
|
||||||
<count-to
|
<count-to
|
||||||
:start-val="startVal"
|
:start-val="startVal"
|
||||||
:end-val="endVal"
|
:end-val="endVal"
|
||||||
@ -8,7 +8,7 @@
|
|||||||
:autoplay="autoplay"
|
:autoplay="autoplay"
|
||||||
:separator="separator"
|
:separator="separator"
|
||||||
/>
|
/>
|
||||||
</no-ssr>
|
</client-only>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<ds-form v-model="form" @submit="handleSubmit">
|
<ds-form v-model="form" @submit="handleSubmit">
|
||||||
<template slot-scope="{ errors }">
|
<template slot-scope="{ errors }">
|
||||||
<ds-card>
|
<ds-card>
|
||||||
<!-- with no-ssr the content is not shown -->
|
<!-- with client-only the content is not shown -->
|
||||||
<hc-editor ref="editor" :users="users" :value="form.content" @input="updateEditorContent" />
|
<hc-editor ref="editor" :users="users" :value="form.content" @input="updateEditorContent" />
|
||||||
<ds-space />
|
<ds-space />
|
||||||
<ds-flex :gutter="{ base: 'small', md: 'small', sm: 'x-large', xs: 'x-large' }">
|
<ds-flex :gutter="{ base: 'small', md: 'small', sm: 'x-large', xs: 'x-large' }">
|
||||||
|
|||||||
@ -10,7 +10,7 @@ localVue.use(Vuex)
|
|||||||
localVue.use(Styleguide)
|
localVue.use(Styleguide)
|
||||||
localVue.use(Filters)
|
localVue.use(Filters)
|
||||||
|
|
||||||
config.stubs['no-ssr'] = '<span><slot /></span>'
|
config.stubs['client-only'] = '<span><slot /></span>'
|
||||||
config.stubs['v-popover'] = '<span><slot /></span>'
|
config.stubs['v-popover'] = '<span><slot /></span>'
|
||||||
|
|
||||||
describe('PostCard', () => {
|
describe('PostCard', () => {
|
||||||
|
|||||||
@ -13,9 +13,9 @@
|
|||||||
<ds-space margin-bottom="small" />
|
<ds-space margin-bottom="small" />
|
||||||
<!-- Username, Image & Date of Post -->
|
<!-- Username, Image & Date of Post -->
|
||||||
<div>
|
<div>
|
||||||
<no-ssr>
|
<client-only>
|
||||||
<hc-user :user="post.author" :trunc="35" :date-time="post.createdAt" />
|
<hc-user :user="post.author" :trunc="35" :date-time="post.createdAt" />
|
||||||
</no-ssr>
|
</client-only>
|
||||||
<hc-ribbon :text="$t('post.name')" />
|
<hc-ribbon :text="$t('post.name')" />
|
||||||
</div>
|
</div>
|
||||||
<ds-space margin-bottom="small" />
|
<ds-space margin-bottom="small" />
|
||||||
@ -42,7 +42,7 @@
|
|||||||
:icon="category.icon"
|
:icon="category.icon"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<no-ssr>
|
<client-only>
|
||||||
<div style="display: inline-block; float: right">
|
<div style="display: inline-block; float: right">
|
||||||
<!-- Shouts Count -->
|
<!-- Shouts Count -->
|
||||||
<span :style="{ opacity: post.shoutedCount ? 1 : 0.5 }">
|
<span :style="{ opacity: post.shoutedCount ? 1 : 0.5 }">
|
||||||
@ -63,7 +63,7 @@
|
|||||||
:is-owner="isAuthor"
|
:is-owner="isAuthor"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</no-ssr>
|
</client-only>
|
||||||
</template>
|
</template>
|
||||||
</ds-card>
|
</ds-card>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -25,9 +25,9 @@
|
|||||||
<div v-if="dateTime" style="display: inline;">
|
<div v-if="dateTime" style="display: inline;">
|
||||||
<ds-text align="left" size="small" color="soft">
|
<ds-text align="left" size="small" color="soft">
|
||||||
<ds-icon name="clock" />
|
<ds-icon name="clock" />
|
||||||
<no-ssr>
|
<client-only>
|
||||||
<hc-relative-date-time :date-time="dateTime" />
|
<hc-relative-date-time :date-time="dateTime" />
|
||||||
</no-ssr>
|
</client-only>
|
||||||
</ds-text>
|
</ds-text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,16 +8,17 @@ const localVue = createLocalVue()
|
|||||||
localVue.use(Styleguide)
|
localVue.use(Styleguide)
|
||||||
localVue.use(Filters)
|
localVue.use(Filters)
|
||||||
|
|
||||||
config.stubs['no-ssr'] = '<span><slot /></span>'
|
config.stubs['client-only'] = '<span><slot /></span>'
|
||||||
|
|
||||||
describe('Notification', () => {
|
describe('Notification', () => {
|
||||||
let stubs
|
let stubs
|
||||||
let mocks
|
let mocks
|
||||||
let propsData
|
let propsData
|
||||||
|
let wrapper
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
propsData = {}
|
propsData = {}
|
||||||
mocks = {
|
mocks = {
|
||||||
$t: jest.fn(),
|
$t: key => key,
|
||||||
}
|
}
|
||||||
stubs = {
|
stubs = {
|
||||||
NuxtLink: RouterLinkStub,
|
NuxtLink: RouterLinkStub,
|
||||||
@ -33,37 +34,159 @@ describe('Notification', () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('given a notification', () => {
|
describe('given a notification about a comment on a post', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
propsData.notification = {
|
propsData.notification = {
|
||||||
post: {
|
reason: 'comment_on_post',
|
||||||
title: "It's a title",
|
post: null,
|
||||||
id: 'post-1',
|
comment: {
|
||||||
slug: 'its-a-title',
|
id: 'comment-1',
|
||||||
contentExcerpt: '<a href="/profile/u3" target="_blank">@jenny-rostock</a> is the best',
|
contentExcerpt:
|
||||||
|
'<a href="/profile/u123" target="_blank">@dagobert-duck</a> is the best on this comment.',
|
||||||
|
post: {
|
||||||
|
title: "It's a post title",
|
||||||
|
id: 'post-1',
|
||||||
|
slug: 'its-a-title',
|
||||||
|
contentExcerpt: 'Post content.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders reason', () => {
|
||||||
|
wrapper = Wrapper()
|
||||||
|
expect(wrapper.find('.reason-text-for-test').text()).toEqual(
|
||||||
|
'notifications.menu.comment_on_post',
|
||||||
|
)
|
||||||
|
})
|
||||||
it('renders title', () => {
|
it('renders title', () => {
|
||||||
expect(Wrapper().text()).toContain("It's a title")
|
wrapper = Wrapper()
|
||||||
|
expect(wrapper.text()).toContain("It's a post title")
|
||||||
|
})
|
||||||
|
it('renders the "Comment:"', () => {
|
||||||
|
wrapper = Wrapper()
|
||||||
|
expect(wrapper.text()).toContain('Comment:')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the contentExcerpt', () => {
|
it('renders the contentExcerpt', () => {
|
||||||
expect(Wrapper().text()).toContain('@jenny-rostock is the best')
|
wrapper = Wrapper()
|
||||||
|
expect(wrapper.text()).toContain('@dagobert-duck is the best on this comment.')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('has no class "read"', () => {
|
it('has no class "read"', () => {
|
||||||
expect(Wrapper().classes()).not.toContain('read')
|
wrapper = Wrapper()
|
||||||
|
expect(wrapper.classes()).not.toContain('read')
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('that is read', () => {
|
describe('that is read', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
propsData.notification.read = true
|
propsData.notification.read = true
|
||||||
|
wrapper = Wrapper()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('has class "read"', () => {
|
it('has class "read"', () => {
|
||||||
expect(Wrapper().classes()).toContain('read')
|
expect(wrapper.classes()).toContain('read')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('given a notification about a mention in a post', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
propsData.notification = {
|
||||||
|
reason: 'mentioned_in_post',
|
||||||
|
post: {
|
||||||
|
title: "It's a post title",
|
||||||
|
id: 'post-1',
|
||||||
|
slug: 'its-a-title',
|
||||||
|
contentExcerpt:
|
||||||
|
'<a href="/profile/u3" target="_blank">@jenny-rostock</a> is the best on this post.',
|
||||||
|
},
|
||||||
|
comment: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders reason', () => {
|
||||||
|
wrapper = Wrapper()
|
||||||
|
expect(wrapper.find('.reason-text-for-test').text()).toEqual(
|
||||||
|
'notifications.menu.mentioned_in_post',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
it('renders title', () => {
|
||||||
|
wrapper = Wrapper()
|
||||||
|
expect(wrapper.text()).toContain("It's a post title")
|
||||||
|
})
|
||||||
|
it('renders the contentExcerpt', () => {
|
||||||
|
wrapper = Wrapper()
|
||||||
|
expect(wrapper.text()).toContain('@jenny-rostock is the best on this post.')
|
||||||
|
})
|
||||||
|
it('has no class "read"', () => {
|
||||||
|
wrapper = Wrapper()
|
||||||
|
expect(wrapper.classes()).not.toContain('read')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('that is read', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
propsData.notification.read = true
|
||||||
|
wrapper = Wrapper()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has class "read"', () => {
|
||||||
|
expect(wrapper.classes()).toContain('read')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('given a notification about a mention in a comment', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
propsData.notification = {
|
||||||
|
reason: 'mentioned_in_comment',
|
||||||
|
post: null,
|
||||||
|
comment: {
|
||||||
|
id: 'comment-1',
|
||||||
|
contentExcerpt:
|
||||||
|
'<a href="/profile/u123" target="_blank">@dagobert-duck</a> is the best on this comment.',
|
||||||
|
post: {
|
||||||
|
title: "It's a post title",
|
||||||
|
id: 'post-1',
|
||||||
|
slug: 'its-a-title',
|
||||||
|
contentExcerpt: 'Post content.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders reason', () => {
|
||||||
|
wrapper = Wrapper()
|
||||||
|
expect(wrapper.find('.reason-text-for-test').text()).toEqual(
|
||||||
|
'notifications.menu.mentioned_in_comment',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
it('renders title', () => {
|
||||||
|
wrapper = Wrapper()
|
||||||
|
expect(wrapper.text()).toContain("It's a post title")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the "Comment:"', () => {
|
||||||
|
wrapper = Wrapper()
|
||||||
|
expect(wrapper.text()).toContain('Comment:')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the contentExcerpt', () => {
|
||||||
|
wrapper = Wrapper()
|
||||||
|
expect(wrapper.text()).toContain('@dagobert-duck is the best on this comment.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has no class "read"', () => {
|
||||||
|
wrapper = Wrapper()
|
||||||
|
expect(wrapper.classes()).not.toContain('read')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('that is read', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
propsData.notification.read = true
|
||||||
|
wrapper = Wrapper()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has class "read"', () => {
|
||||||
|
expect(wrapper.classes()).toContain('read')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<ds-space :class="[{ read: notification.read }, notification]" margin-bottom="x-small">
|
<ds-space :class="[{ read: notification.read }, notification]" margin-bottom="x-small">
|
||||||
<no-ssr>
|
<client-only>
|
||||||
<ds-space margin-bottom="x-small">
|
<ds-space margin-bottom="x-small">
|
||||||
<hc-user
|
<hc-user
|
||||||
v-if="resourceType == 'Post'"
|
v-if="resourceType == 'Post'"
|
||||||
@ -10,10 +10,10 @@
|
|||||||
/>
|
/>
|
||||||
<hc-user v-else :user="comment.author" :date-time="comment.createdAt" :trunc="35" />
|
<hc-user v-else :user="comment.author" :date-time="comment.createdAt" :trunc="35" />
|
||||||
</ds-space>
|
</ds-space>
|
||||||
<ds-text color="soft">
|
<ds-text class="reason-text-for-test" color="soft">
|
||||||
{{ $t('notifications.menu.mentioned', { resource: resourceType }) }}
|
{{ $t(`notifications.menu.${notification.reason}`) }}
|
||||||
</ds-text>
|
</ds-text>
|
||||||
</no-ssr>
|
</client-only>
|
||||||
<ds-space margin-bottom="x-small" />
|
<ds-space margin-bottom="x-small" />
|
||||||
<nuxt-link
|
<nuxt-link
|
||||||
class="notification-mention-post"
|
class="notification-mention-post"
|
||||||
|
|||||||
@ -13,7 +13,7 @@ localVue.use(Styleguide)
|
|||||||
localVue.use(Filters)
|
localVue.use(Filters)
|
||||||
localVue.filter('truncate', string => string)
|
localVue.filter('truncate', string => string)
|
||||||
|
|
||||||
config.stubs['no-ssr'] = '<span><slot /></span>'
|
config.stubs['client-only'] = '<span><slot /></span>'
|
||||||
config.stubs['v-popover'] = '<span><slot /></span>'
|
config.stubs['v-popover'] = '<span><slot /></span>'
|
||||||
|
|
||||||
describe('NotificationList.vue', () => {
|
describe('NotificationList.vue', () => {
|
||||||
|
|||||||
@ -78,12 +78,13 @@ export default i18n => {
|
|||||||
|
|
||||||
export const currentUserNotificationsQuery = () => {
|
export const currentUserNotificationsQuery = () => {
|
||||||
return gql`
|
return gql`
|
||||||
{
|
query {
|
||||||
currentUser {
|
currentUser {
|
||||||
id
|
id
|
||||||
notifications(read: false, orderBy: createdAt_desc) {
|
notifications(read: false, orderBy: createdAt_desc) {
|
||||||
id
|
id
|
||||||
read
|
read
|
||||||
|
reason
|
||||||
createdAt
|
createdAt
|
||||||
post {
|
post {
|
||||||
id
|
id
|
||||||
|
|||||||
@ -37,14 +37,14 @@
|
|||||||
:width="{ base: '15%', sm: '15%', md: '10%', lg: '10%' }"
|
:width="{ base: '15%', sm: '15%', md: '10%', lg: '10%' }"
|
||||||
:class="{ 'hide-mobile-menu': !toggleMobileMenu }"
|
:class="{ 'hide-mobile-menu': !toggleMobileMenu }"
|
||||||
>
|
>
|
||||||
<no-ssr>
|
<client-only>
|
||||||
<filter-posts
|
<filter-posts
|
||||||
v-show="showFilterPostsDropdown"
|
v-show="showFilterPostsDropdown"
|
||||||
placement="top-start"
|
placement="top-start"
|
||||||
offset="8"
|
offset="8"
|
||||||
:categories="categories"
|
:categories="categories"
|
||||||
/>
|
/>
|
||||||
</no-ssr>
|
</client-only>
|
||||||
</ds-flex-item>
|
</ds-flex-item>
|
||||||
<ds-flex-item :width="{ base: '100%', sm: '100%', md: '10%', lg: '2%' }" />
|
<ds-flex-item :width="{ base: '100%', sm: '100%', md: '10%', lg: '2%' }" />
|
||||||
<ds-flex-item
|
<ds-flex-item
|
||||||
@ -59,14 +59,14 @@
|
|||||||
'hide-mobile-menu': !toggleMobileMenu,
|
'hide-mobile-menu': !toggleMobileMenu,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<no-ssr>
|
<client-only>
|
||||||
<locale-switch class="topbar-locale-switch" placement="top" offset="8" />
|
<locale-switch class="topbar-locale-switch" placement="top" offset="8" />
|
||||||
</no-ssr>
|
</client-only>
|
||||||
<template v-if="isLoggedIn">
|
<template v-if="isLoggedIn">
|
||||||
<no-ssr>
|
<client-only>
|
||||||
<notification-menu placement="top" />
|
<notification-menu placement="top" />
|
||||||
</no-ssr>
|
</client-only>
|
||||||
<no-ssr>
|
<client-only>
|
||||||
<dropdown class="avatar-menu" offset="8">
|
<dropdown class="avatar-menu" offset="8">
|
||||||
<template slot="default" slot-scope="{ toggleMenu }">
|
<template slot="default" slot-scope="{ toggleMenu }">
|
||||||
<a
|
<a
|
||||||
@ -113,7 +113,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</dropdown>
|
</dropdown>
|
||||||
</no-ssr>
|
</client-only>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</ds-flex-item>
|
</ds-flex-item>
|
||||||
@ -140,9 +140,9 @@
|
|||||||
<nuxt-link to="/changelog">{{ $t('site.changelog') }}</nuxt-link>
|
<nuxt-link to="/changelog">{{ $t('site.changelog') }}</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
<div id="overlay" />
|
<div id="overlay" />
|
||||||
<no-ssr>
|
<client-only>
|
||||||
<modal />
|
<modal />
|
||||||
</no-ssr>
|
</client-only>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
"all": "Alle"
|
"all": "Alle"
|
||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"header": "Filtern nach..."
|
"header": "Filtern nach …"
|
||||||
},
|
},
|
||||||
"followers": {
|
"followers": {
|
||||||
"label": "Benutzern, denen ich folge"
|
"label": "Benutzern, denen ich folge"
|
||||||
@ -96,7 +96,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"editor": {
|
"editor": {
|
||||||
"placeholder": "Schreib etwas Inspirierendes...",
|
"placeholder": "Schreib etwas Inspirierendes …",
|
||||||
"mention": {
|
"mention": {
|
||||||
"noUsersFound": "Keine Benutzer gefunden"
|
"noUsersFound": "Keine Benutzer gefunden"
|
||||||
},
|
},
|
||||||
@ -132,7 +132,9 @@
|
|||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"menu": {
|
"menu": {
|
||||||
"mentioned": "hat dich in einem {resource} erwähnt"
|
"mentioned_in_post": "Hat dich in einem Beitrag erwähnt …",
|
||||||
|
"mentioned_in_comment": "Hat dich in einem Kommentar erwähnt …",
|
||||||
|
"comment_on_post": "Hat deinen Beitrag kommentiert …"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@ -304,7 +306,7 @@
|
|||||||
},
|
},
|
||||||
"comment": {
|
"comment": {
|
||||||
"content": {
|
"content": {
|
||||||
"unavailable-placeholder": "...dieser Kommentar ist nicht mehr verfügbar"
|
"unavailable-placeholder": "… dieser Kommentar ist nicht mehr verfügbar"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"edit": "Kommentar bearbeiten",
|
"edit": "Kommentar bearbeiten",
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
"all": "All"
|
"all": "All"
|
||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"header": "Filter by..."
|
"header": "Filter by …"
|
||||||
},
|
},
|
||||||
"followers": {
|
"followers": {
|
||||||
"label": "Users I follow"
|
"label": "Users I follow"
|
||||||
@ -96,7 +96,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"editor": {
|
"editor": {
|
||||||
"placeholder": "Leave your inspirational thoughts...",
|
"placeholder": "Leave your inspirational thoughts …",
|
||||||
"mention": {
|
"mention": {
|
||||||
"noUsersFound": "No users found"
|
"noUsersFound": "No users found"
|
||||||
},
|
},
|
||||||
@ -132,7 +132,9 @@
|
|||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"menu": {
|
"menu": {
|
||||||
"mentioned": "mentioned you in a {resource}"
|
"mentioned_in_post": "Mentioned you in a post …",
|
||||||
|
"mentioned_in_comment": "Mentioned you in a comment …",
|
||||||
|
"comment_on_post": "Commented on your post …"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@ -304,7 +306,7 @@
|
|||||||
},
|
},
|
||||||
"comment": {
|
"comment": {
|
||||||
"content": {
|
"content": {
|
||||||
"unavailable-placeholder": "...this comment is not available anymore"
|
"unavailable-placeholder": "… this comment is not available anymore"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"edit": "Edit Comment",
|
"edit": "Edit Comment",
|
||||||
|
|||||||
@ -61,7 +61,7 @@
|
|||||||
"apollo-client": "~2.6.4",
|
"apollo-client": "~2.6.4",
|
||||||
"cookie-universal-nuxt": "~2.0.17",
|
"cookie-universal-nuxt": "~2.0.17",
|
||||||
"cross-env": "~5.2.0",
|
"cross-env": "~5.2.0",
|
||||||
"date-fns": "2.0.0",
|
"date-fns": "2.0.1",
|
||||||
"express": "~4.17.1",
|
"express": "~4.17.1",
|
||||||
"graphql": "~14.5.3",
|
"graphql": "~14.5.3",
|
||||||
"isemail": "^3.2.0",
|
"isemail": "^3.2.0",
|
||||||
|
|||||||
@ -1,23 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<ds-card>
|
<ds-card>
|
||||||
<no-ssr>
|
<client-only>
|
||||||
<ds-space margin="large">
|
<ds-space margin="large">
|
||||||
<ds-flex>
|
<ds-flex>
|
||||||
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
|
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
|
||||||
<ds-space margin="small">
|
<ds-space margin="small">
|
||||||
<ds-number :count="0" :label="$t('admin.dashboard.users')" size="x-large" uppercase>
|
<ds-number :count="0" :label="$t('admin.dashboard.users')" size="x-large" uppercase>
|
||||||
<no-ssr slot="count">
|
<client-only slot="count">
|
||||||
<hc-count-to :end-val="statistics.countUsers" />
|
<hc-count-to :end-val="statistics.countUsers" />
|
||||||
</no-ssr>
|
</client-only>
|
||||||
</ds-number>
|
</ds-number>
|
||||||
</ds-space>
|
</ds-space>
|
||||||
</ds-flex-item>
|
</ds-flex-item>
|
||||||
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
|
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
|
||||||
<ds-space margin="small">
|
<ds-space margin="small">
|
||||||
<ds-number :count="0" :label="$t('admin.dashboard.posts')" size="x-large" uppercase>
|
<ds-number :count="0" :label="$t('admin.dashboard.posts')" size="x-large" uppercase>
|
||||||
<no-ssr slot="count">
|
<client-only slot="count">
|
||||||
<hc-count-to :end-val="statistics.countPosts" />
|
<hc-count-to :end-val="statistics.countPosts" />
|
||||||
</no-ssr>
|
</client-only>
|
||||||
</ds-number>
|
</ds-number>
|
||||||
</ds-space>
|
</ds-space>
|
||||||
</ds-flex-item>
|
</ds-flex-item>
|
||||||
@ -29,9 +29,9 @@
|
|||||||
size="x-large"
|
size="x-large"
|
||||||
uppercase
|
uppercase
|
||||||
>
|
>
|
||||||
<no-ssr slot="count">
|
<client-only slot="count">
|
||||||
<hc-count-to :end-val="statistics.countComments" />
|
<hc-count-to :end-val="statistics.countComments" />
|
||||||
</no-ssr>
|
</client-only>
|
||||||
</ds-number>
|
</ds-number>
|
||||||
</ds-space>
|
</ds-space>
|
||||||
</ds-flex-item>
|
</ds-flex-item>
|
||||||
@ -43,9 +43,9 @@
|
|||||||
size="x-large"
|
size="x-large"
|
||||||
uppercase
|
uppercase
|
||||||
>
|
>
|
||||||
<no-ssr slot="count">
|
<client-only slot="count">
|
||||||
<hc-count-to :end-val="statistics.countNotifications" />
|
<hc-count-to :end-val="statistics.countNotifications" />
|
||||||
</no-ssr>
|
</client-only>
|
||||||
</ds-number>
|
</ds-number>
|
||||||
</ds-space>
|
</ds-space>
|
||||||
</ds-flex-item>
|
</ds-flex-item>
|
||||||
@ -57,9 +57,9 @@
|
|||||||
size="x-large"
|
size="x-large"
|
||||||
uppercase
|
uppercase
|
||||||
>
|
>
|
||||||
<no-ssr slot="count">
|
<client-only slot="count">
|
||||||
<hc-count-to :end-val="statistics.countOrganizations" />
|
<hc-count-to :end-val="statistics.countOrganizations" />
|
||||||
</no-ssr>
|
</client-only>
|
||||||
</ds-number>
|
</ds-number>
|
||||||
</ds-space>
|
</ds-space>
|
||||||
</ds-flex-item>
|
</ds-flex-item>
|
||||||
@ -71,42 +71,42 @@
|
|||||||
size="x-large"
|
size="x-large"
|
||||||
uppercase
|
uppercase
|
||||||
>
|
>
|
||||||
<no-ssr slot="count">
|
<client-only slot="count">
|
||||||
<hc-count-to :end-val="statistics.countProjects" />
|
<hc-count-to :end-val="statistics.countProjects" />
|
||||||
</no-ssr>
|
</client-only>
|
||||||
</ds-number>
|
</ds-number>
|
||||||
</ds-space>
|
</ds-space>
|
||||||
</ds-flex-item>
|
</ds-flex-item>
|
||||||
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
|
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
|
||||||
<ds-space margin="small">
|
<ds-space margin="small">
|
||||||
<ds-number :count="0" :label="$t('admin.dashboard.invites')" size="x-large" uppercase>
|
<ds-number :count="0" :label="$t('admin.dashboard.invites')" size="x-large" uppercase>
|
||||||
<no-ssr slot="count">
|
<client-only slot="count">
|
||||||
<hc-count-to :end-val="statistics.countInvites" />
|
<hc-count-to :end-val="statistics.countInvites" />
|
||||||
</no-ssr>
|
</client-only>
|
||||||
</ds-number>
|
</ds-number>
|
||||||
</ds-space>
|
</ds-space>
|
||||||
</ds-flex-item>
|
</ds-flex-item>
|
||||||
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
|
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
|
||||||
<ds-space margin="small">
|
<ds-space margin="small">
|
||||||
<ds-number :count="0" :label="$t('admin.dashboard.follows')" size="x-large" uppercase>
|
<ds-number :count="0" :label="$t('admin.dashboard.follows')" size="x-large" uppercase>
|
||||||
<no-ssr slot="count">
|
<client-only slot="count">
|
||||||
<hc-count-to :end-val="statistics.countFollows" />
|
<hc-count-to :end-val="statistics.countFollows" />
|
||||||
</no-ssr>
|
</client-only>
|
||||||
</ds-number>
|
</ds-number>
|
||||||
</ds-space>
|
</ds-space>
|
||||||
</ds-flex-item>
|
</ds-flex-item>
|
||||||
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
|
<ds-flex-item :width="{ base: '100%', sm: '50%', md: '33%' }">
|
||||||
<ds-space margin="small">
|
<ds-space margin="small">
|
||||||
<ds-number :count="0" :label="$t('admin.dashboard.shouts')" size="x-large" uppercase>
|
<ds-number :count="0" :label="$t('admin.dashboard.shouts')" size="x-large" uppercase>
|
||||||
<no-ssr slot="count">
|
<client-only slot="count">
|
||||||
<hc-count-to :end-val="statistics.countShouts" />
|
<hc-count-to :end-val="statistics.countShouts" />
|
||||||
</no-ssr>
|
</client-only>
|
||||||
</ds-number>
|
</ds-number>
|
||||||
</ds-space>
|
</ds-space>
|
||||||
</ds-flex-item>
|
</ds-flex-item>
|
||||||
</ds-flex>
|
</ds-flex>
|
||||||
</ds-space>
|
</ds-space>
|
||||||
</no-ssr>
|
</client-only>
|
||||||
</ds-card>
|
</ds-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@ -15,7 +15,7 @@ localVue.use(Filters)
|
|||||||
localVue.use(VTooltip)
|
localVue.use(VTooltip)
|
||||||
localVue.use(InfiniteScroll)
|
localVue.use(InfiniteScroll)
|
||||||
|
|
||||||
config.stubs['no-ssr'] = '<span><slot /></span>'
|
config.stubs['client-only'] = '<span><slot /></span>'
|
||||||
config.stubs['router-link'] = '<span><slot /></span>'
|
config.stubs['router-link'] = '<span><slot /></span>'
|
||||||
config.stubs['nuxt-link'] = '<span><slot /></span>'
|
config.stubs['nuxt-link'] = '<span><slot /></span>'
|
||||||
|
|
||||||
|
|||||||
@ -36,7 +36,7 @@
|
|||||||
</ds-grid-item>
|
</ds-grid-item>
|
||||||
</template>
|
</template>
|
||||||
</masonry-grid>
|
</masonry-grid>
|
||||||
<no-ssr>
|
<client-only>
|
||||||
<ds-button
|
<ds-button
|
||||||
v-tooltip="{ content: 'Create a new Post', placement: 'left', delay: { show: 500 } }"
|
v-tooltip="{ content: 'Create a new Post', placement: 'left', delay: { show: 500 } }"
|
||||||
:path="{ name: 'post-create' }"
|
:path="{ name: 'post-create' }"
|
||||||
@ -45,7 +45,7 @@
|
|||||||
size="x-large"
|
size="x-large"
|
||||||
primary
|
primary
|
||||||
/>
|
/>
|
||||||
</no-ssr>
|
</client-only>
|
||||||
<div
|
<div
|
||||||
v-if="hasMore"
|
v-if="hasMore"
|
||||||
v-infinite-scroll="showMoreContributions"
|
v-infinite-scroll="showMoreContributions"
|
||||||
|
|||||||
@ -10,9 +10,9 @@
|
|||||||
<ds-card class="login-card">
|
<ds-card class="login-card">
|
||||||
<ds-flex gutter="small">
|
<ds-flex gutter="small">
|
||||||
<ds-flex-item :width="{ base: '100%', sm: '50%' }" centered>
|
<ds-flex-item :width="{ base: '100%', sm: '50%' }" centered>
|
||||||
<no-ssr>
|
<client-only>
|
||||||
<locale-switch class="login-locale-switch" offset="5" />
|
<locale-switch class="login-locale-switch" offset="5" />
|
||||||
</no-ssr>
|
</client-only>
|
||||||
<ds-space margin-top="small" margin-bottom="xxx-small" centered>
|
<ds-space margin-top="small" margin-bottom="xxx-small" centered>
|
||||||
<img
|
<img
|
||||||
class="login-image"
|
class="login-image"
|
||||||
|
|||||||
@ -10,7 +10,7 @@ localVue.use(Vuex)
|
|||||||
localVue.use(Styleguide)
|
localVue.use(Styleguide)
|
||||||
localVue.use(Filters)
|
localVue.use(Filters)
|
||||||
|
|
||||||
config.stubs['no-ssr'] = '<span><slot /></span>'
|
config.stubs['client-only'] = '<span><slot /></span>'
|
||||||
|
|
||||||
describe('PostSlug', () => {
|
describe('PostSlug', () => {
|
||||||
let wrapper
|
let wrapper
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
<ds-space margin-bottom="small" />
|
<ds-space margin-bottom="small" />
|
||||||
<hc-user :user="post.author" :date-time="post.createdAt" />
|
<hc-user :user="post.author" :date-time="post.createdAt" />
|
||||||
<!-- Content Menu (can open Modals) -->
|
<!-- Content Menu (can open Modals) -->
|
||||||
<no-ssr>
|
<client-only>
|
||||||
<content-menu
|
<content-menu
|
||||||
placement="bottom-end"
|
placement="bottom-end"
|
||||||
resource-type="contribution"
|
resource-type="contribution"
|
||||||
@ -16,7 +16,7 @@
|
|||||||
:modalsData="menuModalsData"
|
:modalsData="menuModalsData"
|
||||||
:is-owner="isAuthor(post.author ? post.author.id : null)"
|
:is-owner="isAuthor(post.author ? post.author.id : null)"
|
||||||
/>
|
/>
|
||||||
</no-ssr>
|
</client-only>
|
||||||
<ds-space margin-bottom="small" />
|
<ds-space margin-bottom="small" />
|
||||||
<ds-heading tag="h3" no-margin>{{ post.title }}</ds-heading>
|
<ds-heading tag="h3" no-margin>{{ post.title }}</ds-heading>
|
||||||
<ds-space margin-bottom="small" />
|
<ds-space margin-bottom="small" />
|
||||||
|
|||||||
@ -13,7 +13,7 @@ localVue.use(Filters)
|
|||||||
localVue.use(InfiniteScroll)
|
localVue.use(InfiniteScroll)
|
||||||
localVue.filter('date', d => d)
|
localVue.filter('date', d => d)
|
||||||
|
|
||||||
config.stubs['no-ssr'] = '<span><slot /></span>'
|
config.stubs['client-only'] = '<span><slot /></span>'
|
||||||
config.stubs['v-popover'] = '<span><slot /></span>'
|
config.stubs['v-popover'] = '<span><slot /></span>'
|
||||||
config.stubs['nuxt-link'] = '<span><slot /></span>'
|
config.stubs['nuxt-link'] = '<span><slot /></span>'
|
||||||
|
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
</hc-upload>
|
</hc-upload>
|
||||||
<hc-avatar v-else :user="user" class="profile-avatar" size="x-large" />
|
<hc-avatar v-else :user="user" class="profile-avatar" size="x-large" />
|
||||||
<!-- Menu -->
|
<!-- Menu -->
|
||||||
<no-ssr>
|
<client-only>
|
||||||
<content-menu
|
<content-menu
|
||||||
placement="bottom-end"
|
placement="bottom-end"
|
||||||
resource-type="user"
|
resource-type="user"
|
||||||
@ -25,7 +25,7 @@
|
|||||||
@block="block"
|
@block="block"
|
||||||
@unblock="unblock"
|
@unblock="unblock"
|
||||||
/>
|
/>
|
||||||
</no-ssr>
|
</client-only>
|
||||||
<ds-space margin="small">
|
<ds-space margin="small">
|
||||||
<ds-heading tag="h3" align="center" no-margin>{{ userName }}</ds-heading>
|
<ds-heading tag="h3" align="center" no-margin>{{ userName }}</ds-heading>
|
||||||
<ds-text v-if="user.location" align="center" color="soft" size="small">
|
<ds-text v-if="user.location" align="center" color="soft" size="small">
|
||||||
@ -41,18 +41,18 @@
|
|||||||
</ds-space>
|
</ds-space>
|
||||||
<ds-flex>
|
<ds-flex>
|
||||||
<ds-flex-item>
|
<ds-flex-item>
|
||||||
<no-ssr>
|
<client-only>
|
||||||
<ds-number :label="$t('profile.followers')">
|
<ds-number :label="$t('profile.followers')">
|
||||||
<hc-count-to slot="count" :end-val="user.followedByCount" />
|
<hc-count-to slot="count" :end-val="user.followedByCount" />
|
||||||
</ds-number>
|
</ds-number>
|
||||||
</no-ssr>
|
</client-only>
|
||||||
</ds-flex-item>
|
</ds-flex-item>
|
||||||
<ds-flex-item>
|
<ds-flex-item>
|
||||||
<no-ssr>
|
<client-only>
|
||||||
<ds-number :label="$t('profile.following')">
|
<ds-number :label="$t('profile.following')">
|
||||||
<hc-count-to slot="count" :end-val="user.followingCount" />
|
<hc-count-to slot="count" :end-val="user.followingCount" />
|
||||||
</ds-number>
|
</ds-number>
|
||||||
</no-ssr>
|
</client-only>
|
||||||
</ds-flex-item>
|
</ds-flex-item>
|
||||||
</ds-flex>
|
</ds-flex>
|
||||||
<ds-space margin="small">
|
<ds-space margin="small">
|
||||||
@ -89,9 +89,9 @@
|
|||||||
<template v-if="user.following && user.following.length">
|
<template v-if="user.following && user.following.length">
|
||||||
<ds-space v-for="follow in uniq(user.following)" :key="follow.id" margin="x-small">
|
<ds-space v-for="follow in uniq(user.following)" :key="follow.id" margin="x-small">
|
||||||
<!-- TODO: find better solution for rendering errors -->
|
<!-- TODO: find better solution for rendering errors -->
|
||||||
<no-ssr>
|
<client-only>
|
||||||
<user :user="follow" :trunc="15" />
|
<user :user="follow" :trunc="15" />
|
||||||
</no-ssr>
|
</client-only>
|
||||||
</ds-space>
|
</ds-space>
|
||||||
<ds-space v-if="user.followingCount - user.following.length" margin="small">
|
<ds-space v-if="user.followingCount - user.following.length" margin="small">
|
||||||
<ds-text size="small" color="softer">
|
<ds-text size="small" color="softer">
|
||||||
@ -119,9 +119,9 @@
|
|||||||
<template v-if="user.followedBy && user.followedBy.length">
|
<template v-if="user.followedBy && user.followedBy.length">
|
||||||
<ds-space v-for="follow in uniq(user.followedBy)" :key="follow.id" margin="x-small">
|
<ds-space v-for="follow in uniq(user.followedBy)" :key="follow.id" margin="x-small">
|
||||||
<!-- TODO: find better solution for rendering errors -->
|
<!-- TODO: find better solution for rendering errors -->
|
||||||
<no-ssr>
|
<client-only>
|
||||||
<user :user="follow" :trunc="15" />
|
<user :user="follow" :trunc="15" />
|
||||||
</no-ssr>
|
</client-only>
|
||||||
</ds-space>
|
</ds-space>
|
||||||
<ds-space v-if="user.followedByCount - user.followedBy.length" margin="small">
|
<ds-space v-if="user.followedByCount - user.followedBy.length" margin="small">
|
||||||
<ds-text size="small" color="softer">
|
<ds-text size="small" color="softer">
|
||||||
@ -166,33 +166,33 @@
|
|||||||
<li class="Tabs__tab Tab pointer" :class="{ active: tabActive === 'post' }">
|
<li class="Tabs__tab Tab pointer" :class="{ active: tabActive === 'post' }">
|
||||||
<a @click="handleTab('post')">
|
<a @click="handleTab('post')">
|
||||||
<ds-space margin="small">
|
<ds-space margin="small">
|
||||||
<no-ssr placeholder="Loading...">
|
<client-only placeholder="Loading...">
|
||||||
<ds-number :label="$t('common.post', null, user.contributionsCount)">
|
<ds-number :label="$t('common.post', null, user.contributionsCount)">
|
||||||
<hc-count-to slot="count" :end-val="user.contributionsCount" />
|
<hc-count-to slot="count" :end-val="user.contributionsCount" />
|
||||||
</ds-number>
|
</ds-number>
|
||||||
</no-ssr>
|
</client-only>
|
||||||
</ds-space>
|
</ds-space>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="Tabs__tab Tab pointer" :class="{ active: tabActive === 'comment' }">
|
<li class="Tabs__tab Tab pointer" :class="{ active: tabActive === 'comment' }">
|
||||||
<a @click="handleTab('comment')">
|
<a @click="handleTab('comment')">
|
||||||
<ds-space margin="small">
|
<ds-space margin="small">
|
||||||
<no-ssr placeholder="Loading...">
|
<client-only placeholder="Loading...">
|
||||||
<ds-number :label="$t('profile.commented')">
|
<ds-number :label="$t('profile.commented')">
|
||||||
<hc-count-to slot="count" :end-val="user.commentedCount" />
|
<hc-count-to slot="count" :end-val="user.commentedCount" />
|
||||||
</ds-number>
|
</ds-number>
|
||||||
</no-ssr>
|
</client-only>
|
||||||
</ds-space>
|
</ds-space>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="Tabs__tab Tab pointer" :class="{ active: tabActive === 'shout' }">
|
<li class="Tabs__tab Tab pointer" :class="{ active: tabActive === 'shout' }">
|
||||||
<a @click="handleTab('shout')">
|
<a @click="handleTab('shout')">
|
||||||
<ds-space margin="small">
|
<ds-space margin="small">
|
||||||
<no-ssr placeholder="Loading...">
|
<client-only placeholder="Loading...">
|
||||||
<ds-number :label="$t('profile.shouted')">
|
<ds-number :label="$t('profile.shouted')">
|
||||||
<hc-count-to slot="count" :end-val="user.shoutedCount" />
|
<hc-count-to slot="count" :end-val="user.shoutedCount" />
|
||||||
</ds-number>
|
</ds-number>
|
||||||
</no-ssr>
|
</client-only>
|
||||||
</ds-space>
|
</ds-space>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@ -13,7 +13,7 @@ Vue.component('nuxt-link', {
|
|||||||
},
|
},
|
||||||
template: '<a href="#" @click.prevent="log()"><slot>NuxtLink</slot></a>',
|
template: '<a href="#" @click.prevent="log()"><slot>NuxtLink</slot></a>',
|
||||||
})
|
})
|
||||||
Vue.component('no-ssr', {
|
Vue.component('client-only', {
|
||||||
render() {
|
render() {
|
||||||
return this.$slots.default
|
return this.$slots.default
|
||||||
},
|
},
|
||||||
|
|||||||
@ -5711,10 +5711,10 @@ data-urls@^1.0.0:
|
|||||||
whatwg-mimetype "^2.2.0"
|
whatwg-mimetype "^2.2.0"
|
||||||
whatwg-url "^7.0.0"
|
whatwg-url "^7.0.0"
|
||||||
|
|
||||||
date-fns@2.0.0:
|
date-fns@2.0.1:
|
||||||
version "2.0.0"
|
version "2.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.0.tgz#52f05c6ae1fe0e395670082c72b690ab781682d0"
|
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.1.tgz#c5f30e31d3294918e6b6a82753a4e719120e203d"
|
||||||
integrity sha512-nGZDA64Ktq5uTWV4LEH3qX+foV4AguT5qxwRlJDzJtf57d4xLNwtwrfb7SzKCoikoae8Bvxf0zdaEG/xWssp/w==
|
integrity sha512-C14oTzTZy8DH1Eq8N78owrCWvf3+cnJw88BTK/N3DYWVxDJuJzPaNdplzYxDYuuXXGvqBcO4Vy5SOrwAooXSWw==
|
||||||
|
|
||||||
date-fns@^1.27.2:
|
date-fns@^1.27.2:
|
||||||
version "1.30.1"
|
version "1.30.1"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user