Merge branch 'master' of github.com:Human-Connection/Human-Connection into post-needs-to-have-category#1222

This commit is contained in:
Matt Rider 2019-08-20 16:13:38 +02:00
commit a000719663
32 changed files with 1168 additions and 508 deletions

View File

@ -90,7 +90,7 @@
"minimatch": "^3.0.4", "minimatch": "^3.0.4",
"neo4j-driver": "~1.7.5", "neo4j-driver": "~1.7.5",
"neo4j-graphql-js": "^2.7.1", "neo4j-graphql-js": "^2.7.1",
"neode": "^0.3.1", "neode": "^0.3.2",
"node-fetch": "~2.6.0", "node-fetch": "~2.6.0",
"nodemailer": "^6.3.0", "nodemailer": "^6.3.0",
"npm-run-all": "~4.1.5", "npm-run-all": "~4.1.5",
@ -98,7 +98,7 @@
"sanitize-html": "~1.20.1", "sanitize-html": "~1.20.1",
"slug": "~1.1.0", "slug": "~1.1.0",
"trunc-html": "~1.1.2", "trunc-html": "~1.1.2",
"uuid": "~3.3.2", "uuid": "~3.3.3",
"wait-on": "~3.3.0" "wait-on": "~3.3.0"
}, },
"devDependencies": { "devDependencies": {
@ -115,14 +115,14 @@
"chai": "~4.2.0", "chai": "~4.2.0",
"cucumber": "~5.1.0", "cucumber": "~5.1.0",
"eslint": "~6.2.0", "eslint": "~6.2.0",
"eslint-config-prettier": "~6.0.0", "eslint-config-prettier": "~6.1.0",
"eslint-config-standard": "~13.0.1", "eslint-config-standard": "~13.0.1",
"eslint-plugin-import": "~2.18.2", "eslint-plugin-import": "~2.18.2",
"eslint-plugin-jest": "~22.15.1", "eslint-plugin-jest": "~22.15.1",
"eslint-plugin-node": "~9.1.0", "eslint-plugin-node": "~9.1.0",
"eslint-plugin-prettier": "~3.1.0", "eslint-plugin-prettier": "~3.1.0",
"eslint-plugin-promise": "~4.2.1", "eslint-plugin-promise": "~4.2.1",
"eslint-plugin-standard": "~4.0.0", "eslint-plugin-standard": "~4.0.1",
"graphql-request": "~1.8.2", "graphql-request": "~1.8.2",
"jest": "~24.9.0", "jest": "~24.9.0",
"nodemon": "~1.19.1", "nodemon": "~1.19.1",

View File

@ -1,39 +1,57 @@
import extractMentionedUsers from './notifications/extractMentionedUsers' import extractMentionedUsers from './notifications/extractMentionedUsers'
import extractHashtags from './hashtags/extractHashtags' import extractHashtags from './hashtags/extractHashtags'
const notify = async (postId, idsOfMentionedUsers, context) => { const notifyMentions = async (label, id, idsOfMentionedUsers, context) => {
if (!idsOfMentionedUsers.length) return
const session = context.driver.session() const session = context.driver.session()
const createdAt = new Date().toISOString() const createdAt = new Date().toISOString()
const cypher = ` let cypher
MATCH(p:Post {id: $postId})<-[:WROTE]-(author:User) if (label === 'Post') {
MATCH(u:User) cypher = `
WHERE u.id in $idsOfMentionedUsers MATCH (post: Post { id: $id })<-[:WROTE]-(author: User)
AND NOT (u)<-[:BLOCKED]-(author) MATCH (user: User)
CREATE(n:Notification{id: apoc.create.uuid(), read: false, createdAt: $createdAt}) WHERE user.id in $idsOfMentionedUsers
MERGE (p)-[:NOTIFIED]->(n)-[:NOTIFIED]->(u) 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, { await session.run(cypher, {
idsOfMentionedUsers, idsOfMentionedUsers,
label,
createdAt, createdAt,
postId, id,
}) })
session.close() session.close()
} }
const updateHashtagsOfPost = async (postId, hashtags, context) => { const updateHashtagsOfPost = async (postId, hashtags, context) => {
if (!hashtags.length) return
const session = context.driver.session() const session = context.driver.session()
// We need two Cypher statements, because the 'MATCH' in the 'cypherDeletePreviousRelations' statement // 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 // 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. // and no new Hashtags and relations will be created.
const cypherDeletePreviousRelations = ` const cypherDeletePreviousRelations = `
MATCH (p:Post { id: $postId })-[previousRelations:TAGGED]->(t:Tag) MATCH (p: Post { id: $postId })-[previousRelations: TAGGED]->(t: Tag)
DELETE previousRelations DELETE previousRelations
RETURN p, t RETURN p, t
` `
const cypherCreateNewTagsAndRelations = ` const cypherCreateNewTagsAndRelations = `
MATCH (p:Post { id: $postId}) MATCH (p: Post { id: $postId})
UNWIND $hashtags AS tagName UNWIND $hashtags AS tagName
MERGE (t:Tag { id: tagName, name: tagName, disabled: false, deleted: false }) MERGE (t: Tag { id: tagName, name: tagName, disabled: false, deleted: false })
MERGE (p)-[:TAGGED]->(t) MERGE (p)-[:TAGGED]->(t)
RETURN p, t RETURN p, t
` `
@ -47,24 +65,32 @@ const updateHashtagsOfPost = async (postId, hashtags, context) => {
session.close() session.close()
} }
const handleContentData = async (resolve, root, args, context, resolveInfo) => { const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => {
// extract user ids before xss-middleware removes classes via the following "resolve" call
const idsOfMentionedUsers = extractMentionedUsers(args.content) const idsOfMentionedUsers = extractMentionedUsers(args.content)
// extract tag (hashtag) ids before xss-middleware removes classes via the following "resolve" call
const hashtags = extractHashtags(args.content) const hashtags = extractHashtags(args.content)
// removes classes from the content
const post = await resolve(root, args, context, resolveInfo) const post = await resolve(root, args, context, resolveInfo)
await notify(post.id, idsOfMentionedUsers, context) await notifyMentions('Post', post.id, idsOfMentionedUsers, context)
await updateHashtagsOfPost(post.id, hashtags, context) await updateHashtagsOfPost(post.id, hashtags, context)
return post 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 { export default {
Mutation: { Mutation: {
CreatePost: handleContentData, CreatePost: handleContentDataOfPost,
UpdatePost: handleContentData, UpdatePost: handleContentDataOfPost,
CreateComment: handleContentDataOfComment,
UpdateComment: handleContentDataOfComment,
}, },
} }

View File

@ -75,6 +75,9 @@ describe('notifications', () => {
post { post {
content content
} }
comment {
content
}
} }
} }
} }
@ -86,12 +89,12 @@ describe('notifications', () => {
}) })
describe('given another user', () => { describe('given another user', () => {
let author let postAuthor
beforeEach(async () => { beforeEach(async () => {
author = await instance.create('User', { postAuthor = await instance.create('User', {
email: 'author@example.org', email: 'post-author@example.org',
password: '1234', password: '1234',
id: 'author', id: 'postAuthor',
}) })
}) })
@ -101,7 +104,7 @@ describe('notifications', () => {
'Hey <a class="mention" data-mention-id="you" href="/profile/you/al-capone">@al-capone</a> how do you do?' 'Hey <a class="mention" data-mention-id="you" href="/profile/you/al-capone">@al-capone</a> how do you do?'
const createPostAction = async () => { const createPostAction = async () => {
authenticatedUser = await author.toJson() authenticatedUser = await postAuthor.toJson()
await mutate({ await mutate({
mutation: createPostMutation, mutation: createPostMutation,
variables: { id: 'p47', title, content, categoryIds }, variables: { id: 'p47', title, content, categoryIds },
@ -115,12 +118,27 @@ describe('notifications', () => {
'Hey <a class="mention" data-mention-id="you" href="/profile/you/al-capone" target="_blank">@al-capone</a> how do you do?' '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({ const expected = expect.objectContaining({
data: { data: {
currentUser: { notifications: [{ read: false, post: { content: expectedContent } }] }, currentUser: {
notifications: [
{
read: false,
post: {
content: expectedContent,
},
comment: null,
},
],
},
}, },
}) })
const { query } = createTestClient(server) const { query } = createTestClient(server)
await expect( await expect(
query({ query: notificationQuery, variables: { read: false } }), query({
query: notificationQuery,
variables: {
read: false,
},
}),
).resolves.toEqual(expected) ).resolves.toEqual(expected)
}) })
@ -140,7 +158,7 @@ describe('notifications', () => {
@al-capone @al-capone
</a> </a>
` `
authenticatedUser = await author.toJson() authenticatedUser = await postAuthor.toJson()
await mutate({ await mutate({
mutation: updatePostMutation, mutation: updatePostMutation,
variables: { variables: {
@ -162,31 +180,114 @@ describe('notifications', () => {
data: { data: {
currentUser: { currentUser: {
notifications: [ notifications: [
{ read: false, post: { content: expectedContent } }, {
{ read: false, post: { content: expectedContent } }, read: false,
post: {
content: expectedContent,
},
comment: null,
},
{
read: false,
post: {
content: expectedContent,
},
comment: null,
},
], ],
}, },
}, },
}) })
await expect( await expect(
query({ query: notificationQuery, variables: { read: false } }), query({
query: notificationQuery,
variables: {
read: false,
},
}),
).resolves.toEqual(expected) ).resolves.toEqual(expected)
}) })
}) })
describe('but the author of the post blocked me', () => { describe('but the author of the post blocked me', () => {
beforeEach(async () => { beforeEach(async () => {
await author.relateTo(user, 'blocked') await postAuthor.relateTo(user, 'blocked')
}) })
it('sends no notification', async () => { it('sends no notification', async () => {
await createPostAction() await createPostAction()
const expected = expect.objectContaining({ const expected = expect.objectContaining({
data: { currentUser: { notifications: [] } }, data: {
currentUser: {
notifications: [],
},
},
}) })
const { query } = createTestClient(server) const { query } = createTestClient(server)
await expect( await expect(
query({ query: notificationQuery, variables: { read: false } }), 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) ).resolves.toEqual(expected)
}) })
}) })
@ -234,7 +335,10 @@ describe('Hashtags', () => {
it('both Hashtags are created with the "id" set to their "name"', async () => { it('both Hashtags are created with the "id" set to their "name"', async () => {
const expected = [{ id: 'Democracy' }, { id: 'Liberty' }] const expected = [{ id: 'Democracy' }, { id: 'Liberty' }]
await expect( await expect(
query({ query: postWithHastagsQuery, variables: postWithHastagsVariables }), query({
query: postWithHastagsQuery,
variables: postWithHastagsVariables,
}),
).resolves.toEqual( ).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
data: { data: {
@ -266,11 +370,18 @@ describe('Hashtags', () => {
const expected = [{ id: 'Elections' }, { id: 'Liberty' }] const expected = [{ id: 'Elections' }, { id: 'Liberty' }]
await expect( await expect(
query({ query: postWithHastagsQuery, variables: postWithHastagsVariables }), query({
query: postWithHastagsQuery,
variables: postWithHastagsVariables,
}),
).resolves.toEqual( ).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
data: { data: {
Post: [{ tags: expect.arrayContaining(expected) }], Post: [
{
tags: expect.arrayContaining(expected),
},
],
}, },
}), }),
) )

View File

@ -27,7 +27,7 @@ afterEach(async () => {
}) })
describe('Notification', () => { describe('Notification', () => {
const query = gql` const notificationQuery = gql`
query { query {
Notification { Notification {
id id
@ -38,19 +38,24 @@ describe('Notification', () => {
describe('unauthenticated', () => { describe('unauthenticated', () => {
it('throws authorization error', async () => { it('throws authorization error', async () => {
client = new GraphQLClient(host) client = new GraphQLClient(host)
await expect(client.request(query)).rejects.toThrow('Not Authorised') await expect(client.request(notificationQuery)).rejects.toThrow('Not Authorised')
}) })
}) })
}) })
describe('currentUser { notifications }', () => { describe('currentUser notifications', () => {
const variables = {} const variables = {}
describe('authenticated', () => { describe('authenticated', () => {
let headers let headers
beforeEach(async () => { beforeEach(async () => {
headers = await login({ email: 'test@example.org', password: '1234' }) headers = await login({
client = new GraphQLClient(host, { headers }) email: 'test@example.org',
password: '1234',
})
client = new GraphQLClient(host, {
headers,
})
}) })
describe('given some notifications', () => { describe('given some notifications', () => {
@ -62,24 +67,92 @@ describe('currentUser { notifications }', () => {
} }
await Promise.all([ await Promise.all([
factory.create('User', neighborParams), factory.create('User', neighborParams),
factory.create('Notification', { id: 'not-for-you' }), factory.create('Notification', {
factory.create('Notification', { id: 'already-seen', read: true }), id: 'post-mention-not-for-you',
}),
factory.create('Notification', {
id: 'post-mention-already-seen',
read: true,
}),
factory.create('Notification', {
id: 'post-mention-unseen',
}),
factory.create('Notification', {
id: 'comment-mention-not-for-you',
}),
factory.create('Notification', {
id: 'comment-mention-already-seen',
read: true,
}),
factory.create('Notification', {
id: 'comment-mention-unseen',
}),
]) ])
await factory.create('Notification', { id: 'unseen' })
await factory.authenticateAs(neighborParams) await factory.authenticateAs(neighborParams)
await factory.create('Post', { id: 'p1', categoryIds }) await factory.create('Post', { id: 'p1', categoryIds })
await Promise.all([ await Promise.all([
factory.relate('Notification', 'User', { from: 'not-for-you', to: 'neighbor' }), factory.relate('Notification', 'User', {
factory.relate('Notification', 'Post', { from: 'p1', to: 'not-for-you', categoryIds }), from: 'post-mention-not-for-you',
factory.relate('Notification', 'User', { from: 'unseen', to: 'you' }), to: 'neighbor',
factory.relate('Notification', 'Post', { from: 'p1', to: 'unseen', categoryIds }), }),
factory.relate('Notification', 'User', { from: 'already-seen', to: 'you' }), factory.relate('Notification', 'Post', {
factory.relate('Notification', 'Post', { from: 'p1', to: 'already-seen', categoryIds }), from: 'p1',
to: 'post-mention-not-for-you',
}),
factory.relate('Notification', 'User', {
from: 'post-mention-unseen',
to: 'you',
}),
factory.relate('Notification', 'Post', {
from: 'p1',
to: 'post-mention-unseen',
}),
factory.relate('Notification', 'User', {
from: 'post-mention-already-seen',
to: 'you',
}),
factory.relate('Notification', 'Post', {
from: 'p1',
to: 'post-mention-already-seen',
}),
])
// Comment and its notifications
await Promise.all([
factory.create('Comment', {
id: 'c1',
postId: 'p1',
}),
])
await Promise.all([
factory.relate('Notification', 'User', {
from: 'comment-mention-not-for-you',
to: 'neighbor',
}),
factory.relate('Notification', 'Comment', {
from: 'c1',
to: 'comment-mention-not-for-you',
}),
factory.relate('Notification', 'User', {
from: 'comment-mention-unseen',
to: 'you',
}),
factory.relate('Notification', 'Comment', {
from: 'c1',
to: 'comment-mention-unseen',
}),
factory.relate('Notification', 'User', {
from: 'comment-mention-already-seen',
to: 'you',
}),
factory.relate('Notification', 'Comment', {
from: 'c1',
to: 'comment-mention-already-seen',
}),
]) ])
}) })
describe('filter for read: false', () => { describe('filter for read: false', () => {
const query = gql` const queryCurrentUserNotificationsFilterRead = gql`
query($read: Boolean) { query($read: Boolean) {
currentUser { currentUser {
notifications(read: $read, orderBy: createdAt_desc) { notifications(read: $read, orderBy: createdAt_desc) {
@ -87,6 +160,9 @@ describe('currentUser { notifications }', () => {
post { post {
id id
} }
comment {
id
}
} }
} }
} }
@ -95,15 +171,32 @@ describe('currentUser { notifications }', () => {
it('returns only unread notifications of current user', async () => { it('returns only unread notifications of current user', async () => {
const expected = { const expected = {
currentUser: { currentUser: {
notifications: [{ id: 'unseen', post: { id: 'p1' } }], notifications: expect.arrayContaining([
{
id: 'post-mention-unseen',
post: {
id: 'p1',
},
comment: null,
},
{
id: 'comment-mention-unseen',
post: null,
comment: {
id: 'c1',
},
},
]),
}, },
} }
await expect(client.request(query, variables)).resolves.toEqual(expected) await expect(
client.request(queryCurrentUserNotificationsFilterRead, variables),
).resolves.toEqual(expected)
}) })
}) })
describe('no filters', () => { describe('no filters', () => {
const query = gql` const queryCurrentUserNotifications = gql`
query { query {
currentUser { currentUser {
notifications(orderBy: createdAt_desc) { notifications(orderBy: createdAt_desc) {
@ -111,6 +204,9 @@ describe('currentUser { notifications }', () => {
post { post {
id id
} }
comment {
id
}
} }
} }
} }
@ -118,13 +214,41 @@ describe('currentUser { notifications }', () => {
it('returns all notifications of current user', async () => { it('returns all notifications of current user', async () => {
const expected = { const expected = {
currentUser: { currentUser: {
notifications: [ notifications: expect.arrayContaining([
{ id: 'unseen', post: { id: 'p1' } }, {
{ id: 'already-seen', post: { id: 'p1' } }, id: 'post-mention-unseen',
], post: {
id: 'p1',
},
comment: null,
},
{
id: 'post-mention-already-seen',
post: {
id: 'p1',
},
comment: null,
},
{
id: 'comment-mention-unseen',
comment: {
id: 'c1',
},
post: null,
},
{
id: 'comment-mention-already-seen',
comment: {
id: 'c1',
},
post: null,
},
]),
}, },
} }
await expect(client.request(query, variables)).resolves.toEqual(expected) await expect(client.request(queryCurrentUserNotifications, variables)).resolves.toEqual(
expected,
)
}) })
}) })
}) })
@ -132,7 +256,7 @@ describe('currentUser { notifications }', () => {
}) })
describe('UpdateNotification', () => { describe('UpdateNotification', () => {
const mutation = gql` const mutationUpdateNotification = gql`
mutation($id: ID!, $read: Boolean) { mutation($id: ID!, $read: Boolean) {
UpdateNotification(id: $id, read: $read) { UpdateNotification(id: $id, read: $read) {
id id
@ -140,9 +264,16 @@ describe('UpdateNotification', () => {
} }
} }
` `
const variables = { id: 'to-be-updated', read: true } const variablesPostUpdateNotification = {
id: 'post-mention-to-be-updated',
read: true,
}
const variablesCommentUpdateNotification = {
id: 'comment-mention-to-be-updated',
read: true,
}
describe('given a notifications', () => { describe('given some notifications', () => {
let headers let headers
beforeEach(async () => { beforeEach(async () => {
@ -152,42 +283,105 @@ describe('UpdateNotification', () => {
password: '1234', password: '1234',
slug: 'mentioned', slug: 'mentioned',
} }
await factory.create('User', mentionedParams) await Promise.all([
await factory.create('Notification', { id: 'to-be-updated' }) factory.create('User', mentionedParams),
factory.create('Notification', {
id: 'post-mention-to-be-updated',
}),
factory.create('Notification', {
id: 'comment-mention-to-be-updated',
}),
])
await factory.authenticateAs(userParams) await factory.authenticateAs(userParams)
await factory.create('Post', { id: 'p1', categoryIds }) await factory.create('Post', { id: 'p1', categoryIds })
await Promise.all([ await Promise.all([
factory.relate('Notification', 'User', { from: 'to-be-updated', to: 'mentioned-1' }), factory.relate('Notification', 'User', {
factory.relate('Notification', 'Post', { from: 'p1', to: 'to-be-updated' }), from: 'post-mention-to-be-updated',
to: 'mentioned-1',
}),
factory.relate('Notification', 'Post', {
from: 'p1',
to: 'post-mention-to-be-updated',
}),
])
// Comment and its notifications
await Promise.all([
factory.create('Comment', {
id: 'c1',
postId: 'p1',
}),
])
await Promise.all([
factory.relate('Notification', 'User', {
from: 'comment-mention-to-be-updated',
to: 'mentioned-1',
}),
factory.relate('Notification', 'Comment', {
from: 'p1',
to: 'comment-mention-to-be-updated',
}),
]) ])
}) })
describe('unauthenticated', () => { describe('unauthenticated', () => {
it('throws authorization error', async () => { it('throws authorization error', async () => {
client = new GraphQLClient(host) client = new GraphQLClient(host)
await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') await expect(
client.request(mutationUpdateNotification, variablesPostUpdateNotification),
).rejects.toThrow('Not Authorised')
}) })
}) })
describe('authenticated', () => { describe('authenticated', () => {
beforeEach(async () => { beforeEach(async () => {
headers = await login({ email: 'test@example.org', password: '1234' }) headers = await login({
client = new GraphQLClient(host, { headers }) email: 'test@example.org',
password: '1234',
})
client = new GraphQLClient(host, {
headers,
})
}) })
it('throws authorization error', async () => { it('throws authorization error', async () => {
await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') await expect(
client.request(mutationUpdateNotification, variablesPostUpdateNotification),
).rejects.toThrow('Not Authorised')
}) })
describe('and owner', () => { describe('and owner', () => {
beforeEach(async () => { beforeEach(async () => {
headers = await login({ email: 'mentioned@example.org', password: '1234' }) headers = await login({
client = new GraphQLClient(host, { headers }) email: 'mentioned@example.org',
password: '1234',
})
client = new GraphQLClient(host, {
headers,
})
}) })
it('updates notification', async () => { it('updates post notification', async () => {
const expected = { UpdateNotification: { id: 'to-be-updated', read: true } } const expected = {
await expect(client.request(mutation, variables)).resolves.toEqual(expected) UpdateNotification: {
id: 'post-mention-to-be-updated',
read: true,
},
}
await expect(
client.request(mutationUpdateNotification, variablesPostUpdateNotification),
).resolves.toEqual(expected)
})
it('updates comment notification', async () => {
const expected = {
UpdateNotification: {
id: 'comment-mention-to-be-updated',
read: true,
},
}
await expect(
client.request(mutationUpdateNotification, variablesCommentUpdateNotification),
).resolves.toEqual(expected)
}) })
}) })
}) })

View File

@ -123,4 +123,3 @@ type SharedInboxEndpoint {
id: ID! id: ID!
uri: String uri: String
} }

View File

@ -3,5 +3,6 @@ type Notification {
read: Boolean read: Boolean
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")
createdAt: String createdAt: String
} }

View File

@ -44,42 +44,49 @@ import Factory from './factories'
f.create('User', { f.create('User', {
id: 'u1', id: 'u1',
name: 'Peter Lustig', name: 'Peter Lustig',
slug: 'peter-lustig',
role: 'admin', role: 'admin',
email: 'admin@example.org', email: 'admin@example.org',
}), }),
f.create('User', { f.create('User', {
id: 'u2', id: 'u2',
name: 'Bob der Baumeister', name: 'Bob der Baumeister',
slug: 'bob-der-baumeister',
role: 'moderator', role: 'moderator',
email: 'moderator@example.org', email: 'moderator@example.org',
}), }),
f.create('User', { f.create('User', {
id: 'u3', id: 'u3',
name: 'Jenny Rostock', name: 'Jenny Rostock',
slug: 'jenny-rostock',
role: 'user', role: 'user',
email: 'user@example.org', email: 'user@example.org',
}), }),
f.create('User', { f.create('User', {
id: 'u4', id: 'u4',
name: 'Tick', name: 'Huey (Tick)',
slug: 'huey-tick',
role: 'user', role: 'user',
email: 'tick@example.org', email: 'huey@example.org',
}), }),
f.create('User', { f.create('User', {
id: 'u5', id: 'u5',
name: 'Trick', name: 'Dewey (Trick)',
slug: 'dewey-trick',
role: 'user', role: 'user',
email: 'trick@example.org', email: 'dewey@example.org',
}), }),
f.create('User', { f.create('User', {
id: 'u6', id: 'u6',
name: 'Track', name: 'Louie (Track)',
slug: 'louie-track',
role: 'user', role: 'user',
email: 'track@example.org', email: 'louie@example.org',
}), }),
f.create('User', { f.create('User', {
id: 'u7', id: 'u7',
name: 'Dagobert', name: 'Dagobert',
slug: 'dagobert',
role: 'user', role: 'user',
email: 'dagobert@example.org', email: 'dagobert@example.org',
}), }),
@ -99,15 +106,15 @@ import Factory from './factories'
password: '1234', password: '1234',
}), }),
Factory().authenticateAs({ Factory().authenticateAs({
email: 'tick@example.org', email: 'huey@example.org',
password: '1234', password: '1234',
}), }),
Factory().authenticateAs({ Factory().authenticateAs({
email: 'trick@example.org', email: 'dewey@example.org',
password: '1234', password: '1234',
}), }),
Factory().authenticateAs({ Factory().authenticateAs({
email: 'track@example.org', email: 'louie@example.org',
password: '1234', password: '1234',
}), }),
]) ])
@ -260,6 +267,10 @@ import Factory from './factories'
'Hey <a class="mention" data-mention-id="u3" href="/profile/u3">@jenny-rostock</a>, what\'s up?' 'Hey <a class="mention" data-mention-id="u3" href="/profile/u3">@jenny-rostock</a>, what\'s up?'
const mention2 = const mention2 =
'Hey <a class="mention" data-mention-id="u3" href="/profile/u3">@jenny-rostock</a>, here is another notification for you!' 'Hey <a class="mention" data-mention-id="u3" href="/profile/u3">@jenny-rostock</a>, here is another notification for you!'
const hashtag1 =
'See <a class="hashtag" href="/search/hashtag/NaturphilosophieYoga">#NaturphilosophieYoga</a> can really help you!'
const hashtagAndMention1 =
'The new physics of <a class="hashtag" href="/search/hashtag/QuantenFlussTheorie">#QuantenFlussTheorie</a> can explain <a class="hashtag" href="/search/hashtag/QuantumGravity">#QuantumGravity</a>! <a class="mention" data-mention-id="u3" href="/profile/u1">@peter-lustig</a> got that already. ;-)'
await Promise.all([ await Promise.all([
asAdmin.create('Post', { asAdmin.create('Post', {
@ -272,6 +283,8 @@ import Factory from './factories'
}), }),
asUser.create('Post', { asUser.create('Post', {
id: 'p2', id: 'p2',
title: `Nature Philosophy Yoga`,
content: `${hashtag1}`,
}), }),
asTick.create('Post', { asTick.create('Post', {
id: 'p3', id: 'p3',
@ -293,6 +306,8 @@ import Factory from './factories'
asUser.create('Post', { asUser.create('Post', {
id: 'p8', id: 'p8',
image: faker.image.unsplash.nature(), image: faker.image.unsplash.nature(),
title: `Quantum Flow Theory explains Quantum Gravity`,
content: `${hashtagAndMention1}`,
}), }),
asTick.create('Post', { asTick.create('Post', {
id: 'p9', id: 'p9',
@ -639,6 +654,11 @@ import Factory from './factories'
}), }),
]) ])
const mentionInComment1 =
'I heard <a class="mention" data-mention-id="u3" href="/profile/u3">@jenny-rostock</a>, practice it since 3 years now.'
const mentionInComment2 =
'Did <a class="mention" data-mention-id="u1" href="/profile/u1">@peter-lustig</a> told you?'
await Promise.all([ await Promise.all([
asUser.create('Comment', { asUser.create('Comment', {
id: 'c1', id: 'c1',
@ -655,6 +675,12 @@ import Factory from './factories'
asTrick.create('Comment', { asTrick.create('Comment', {
id: 'c4', id: 'c4',
postId: 'p2', postId: 'p2',
content: `${mentionInComment1}`,
}),
asUser.create('Comment', {
id: 'c4-1',
postId: 'p2',
content: `${mentionInComment2}`,
}), }),
asModerator.create('Comment', { asModerator.create('Comment', {
id: 'c5', id: 'c5',

View File

@ -3267,10 +3267,10 @@ escodegen@^1.9.1:
optionalDependencies: optionalDependencies:
source-map "~0.6.1" source-map "~0.6.1"
eslint-config-prettier@~6.0.0: eslint-config-prettier@~6.1.0:
version "6.0.0" version "6.1.0"
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-6.0.0.tgz#f429a53bde9fc7660e6353910fd996d6284d3c25" resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-6.1.0.tgz#e6f678ba367fbd1273998d5510f76f004e9dce7b"
integrity sha512-vDrcCFE3+2ixNT5H83g28bO/uYAwibJxerXPj+E7op4qzBCsAV36QfvdAyVOoNxKAH2Os/e01T/2x++V0LPukA== integrity sha512-k9fny9sPjIBQ2ftFTesJV21Rg4R/7a7t7LCtZVrYQiHEp8Nnuk3EGaDmsKSAnsPj0BYcgB2zxzHa2NTkIxcOLg==
dependencies: dependencies:
get-stdin "^6.0.0" get-stdin "^6.0.0"
@ -3351,10 +3351,10 @@ eslint-plugin-promise@~4.2.1:
resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-4.2.1.tgz#845fd8b2260ad8f82564c1222fce44ad71d9418a" resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-4.2.1.tgz#845fd8b2260ad8f82564c1222fce44ad71d9418a"
integrity sha512-VoM09vT7bfA7D+upt+FjeBO5eHIJQBUWki1aPvB+vbNiHS3+oGIJGIeyBtKQTME6UPXXy3vV07OL1tHd3ANuDw== integrity sha512-VoM09vT7bfA7D+upt+FjeBO5eHIJQBUWki1aPvB+vbNiHS3+oGIJGIeyBtKQTME6UPXXy3vV07OL1tHd3ANuDw==
eslint-plugin-standard@~4.0.0: eslint-plugin-standard@~4.0.1:
version "4.0.0" version "4.0.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-standard/-/eslint-plugin-standard-4.0.0.tgz#f845b45109c99cd90e77796940a344546c8f6b5c" resolved "https://registry.yarnpkg.com/eslint-plugin-standard/-/eslint-plugin-standard-4.0.1.tgz#ff0519f7ffaff114f76d1bd7c3996eef0f6e20b4"
integrity sha512-OwxJkR6TQiYMmt1EsNRMe5qG3GsbjlcOhbGUBY4LtavF9DsLaTcoR+j2Tdjqi23oUwKNUqX7qcn5fPStafMdlA== integrity sha512-v/KBnfyaOMPmZc/dmc6ozOdWqekGp7bBGq4jLAecEfPGmfKiWS4sA8sC0LqiV9w5qmXAtXVn4M3p1jSyhY85SQ==
eslint-scope@3.7.1: eslint-scope@3.7.1:
version "3.7.1" version "3.7.1"
@ -6163,7 +6163,7 @@ neo-async@^2.6.0:
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.1.tgz#ac27ada66167fa8849a6addd837f6b189ad2081c" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.1.tgz#ac27ada66167fa8849a6addd837f6b189ad2081c"
integrity sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw== integrity sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw==
neo4j-driver@^1.6.3, neo4j-driver@^1.7.3, neo4j-driver@~1.7.5: neo4j-driver@^1.7.3, neo4j-driver@^1.7.5, neo4j-driver@~1.7.5:
version "1.7.5" version "1.7.5"
resolved "https://registry.yarnpkg.com/neo4j-driver/-/neo4j-driver-1.7.5.tgz#c3fe3677f69c12f26944563d45e7e7d818a685e4" resolved "https://registry.yarnpkg.com/neo4j-driver/-/neo4j-driver-1.7.5.tgz#c3fe3677f69c12f26944563d45e7e7d818a685e4"
integrity sha512-xCD2F5+tp/SD9r5avX5bSoY8u8RH2o793xJ9Ikjz1s5qQy7cFxFbbj2c52uz3BVGhRAx/NmB57VjOquYmmxGtw== integrity sha512-xCD2F5+tp/SD9r5avX5bSoY8u8RH2o793xJ9Ikjz1s5qQy7cFxFbbj2c52uz3BVGhRAx/NmB57VjOquYmmxGtw==
@ -6184,14 +6184,14 @@ neo4j-graphql-js@^2.7.1:
lodash "^4.17.15" lodash "^4.17.15"
neo4j-driver "^1.7.3" neo4j-driver "^1.7.3"
neode@^0.3.1: neode@^0.3.2:
version "0.3.1" version "0.3.2"
resolved "https://registry.yarnpkg.com/neode/-/neode-0.3.1.tgz#d40147bf20d6951b69c9d392fbdd322aeca07816" resolved "https://registry.yarnpkg.com/neode/-/neode-0.3.2.tgz#ced277e1daba26a77c48f5857c30af054f11c7df"
integrity sha512-SdaJmdjQ3PWOH6W1H8Xgd2CLyJs+BPPXPt0jOVNs7naeQH8nWPP6ixDqI6NWDCxwecTdNl//fpAicB9I6hCwEw== integrity sha512-Bm4GBXdXunv8cqUUkJtksIGHDnYdBJf4UHwzFgXbJiDKBAdqfjhzwAPAhf1PrvlFmR4vJva2Bh/XvIghYOiKrA==
dependencies: dependencies:
"@hapi/joi" "^15.1.0" "@hapi/joi" "^15.1.0"
dotenv "^4.0.0" dotenv "^4.0.0"
neo4j-driver "^1.6.3" neo4j-driver "^1.7.5"
uuid "^3.3.2" uuid "^3.3.2"
next-tick@^1.0.0: next-tick@^1.0.0:
@ -8552,10 +8552,10 @@ utils-merge@1.0.1:
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
uuid@^3.1.0, uuid@^3.3.2, uuid@~3.3.2: uuid@^3.1.0, uuid@^3.3.2, uuid@~3.3.3:
version "3.3.2" version "3.3.3"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.3.tgz#4568f0216e78760ee1dbf3a4d2cf53e224112866"
integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA== integrity sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==
v8-compile-cache@^2.0.3: v8-compile-cache@^2.0.3:
version "2.0.3" version "2.0.3"

View File

@ -1,6 +1,6 @@
import { Given, When, Then } from "cypress-cucumber-preprocessor/steps"; import { Given, When, Then } from "cypress-cucumber-preprocessor/steps";
import { getLangByName } from "../../support/helpers"; import { getLangByName } from "../../support/helpers";
import slugify from 'slug' import slugify from "slug";
/* global cy */ /* global cy */
@ -12,7 +12,7 @@ let loginCredentials = {
}; };
const narratorParams = { const narratorParams = {
name: "Peter Pan", name: "Peter Pan",
slug: 'peter-pan', slug: "peter-pan",
avatar: "https://s3.amazonaws.com/uifaces/faces/twitter/nerrsoft/128.jpg", avatar: "https://s3.amazonaws.com/uifaces/faces/twitter/nerrsoft/128.jpg",
...loginCredentials ...loginCredentials
}; };
@ -174,10 +174,10 @@ When("I press {string}", label => {
Given("we have the following posts in our database:", table => { Given("we have the following posts in our database:", table => {
table.hashes().forEach(({ Author, ...postAttributes }, i) => { table.hashes().forEach(({ Author, ...postAttributes }, i) => {
Author = Author || `author-${i}` Author = Author || `author-${i}`;
const userAttributes = { const userAttributes = {
name: Author, name: Author,
email: `${slugify(Author, {lower: true})}@example.org`, email: `${slugify(Author, { lower: true })}@example.org`,
password: "1234" password: "1234"
}; };
postAttributes.deleted = Boolean(postAttributes.deleted); postAttributes.deleted = Boolean(postAttributes.deleted);
@ -363,95 +363,106 @@ Then("there are no notifications in the top menu", () => {
cy.get(".notifications-menu").should("contain", "0"); cy.get(".notifications-menu").should("contain", "0");
}); });
Given("there is an annoying user called {string}", (name) => { Given("there is an annoying user called {string}", name => {
const annoyingParams = { const annoyingParams = {
email: 'spammy-spammer@example.org', email: "spammy-spammer@example.org",
password: '1234', password: "1234"
} };
cy.factory().create('User', { cy.factory().create("User", {
...annoyingParams, ...annoyingParams,
id: 'annoying-user', id: "annoying-user",
name name
}) });
}) });
Given("I am on the profile page of the annoying user", (name) => { Given("I am on the profile page of the annoying user", name => {
cy.openPage('/profile/annoying-user/spammy-spammer'); cy.openPage("/profile/annoying-user/spammy-spammer");
}) });
When("I visit the profile page of the annoying user", (name) => { When("I visit the profile page of the annoying user", name => {
cy.openPage('/profile/annoying-user'); cy.openPage("/profile/annoying-user");
}) });
When("I ", (name) => { When("I ", name => {
cy.openPage('/profile/annoying-user'); cy.openPage("/profile/annoying-user");
}) });
When("I click on {string} from the content menu in the user info box", (button) => { When(
cy.get('.user-content-menu .content-menu-trigger') "I click on {string} from the content menu in the user info box",
.click() button => {
cy.get('.popover .ds-menu-item-link') cy.get(".user-content-menu .content-menu-trigger").click();
.contains(button) cy.get(".popover .ds-menu-item-link")
.click() .contains(button)
}) .click({ force: true });
}
);
When ("I navigate to my {string} settings page", (settingsPage) => { When("I navigate to my {string} settings page", settingsPage => {
cy.get(".avatar-menu").click(); cy.get(".avatar-menu").click();
cy.get(".avatar-menu-popover") cy.get(".avatar-menu-popover")
.find('a[href]').contains("Settings").click() .find("a[href]")
cy.contains('.ds-menu-item-link', settingsPage).click() .contains("Settings")
}) .click();
cy.contains(".ds-menu-item-link", settingsPage).click();
});
Given("I follow the user {string}", (name) => { Given("I follow the user {string}", name => {
cy.neode() cy.neode()
.first('User', { name }).then((followed) => { .first("User", { name })
.then(followed => {
cy.neode() cy.neode()
.first('User', {name: narratorParams.name}) .first("User", { name: narratorParams.name })
.relateTo(followed, 'following') .relateTo(followed, "following");
}) });
}) });
Given("\"Spammy Spammer\" wrote a post {string}", (title) => { Given('"Spammy Spammer" wrote a post {string}', title => {
cy.factory() cy.factory()
.authenticateAs({ .authenticateAs({
email: 'spammy-spammer@example.org', email: "spammy-spammer@example.org",
password: '1234', password: "1234"
}) })
.create("Post", { title }) .create("Post", { title });
}) });
Then("the list of posts of this user is empty", () => { Then("the list of posts of this user is empty", () => {
cy.get('.ds-card-content').not('.post-link') cy.get(".ds-card-content").not(".post-link");
cy.get('.main-container').find('.ds-space.hc-empty') cy.get(".main-container").find(".ds-space.hc-empty");
}) });
Then("nobody is following the user profile anymore", () => { Then("nobody is following the user profile anymore", () => {
cy.get('.ds-card-content').not('.post-link') cy.get(".ds-card-content").not(".post-link");
cy.get('.main-container').contains('.ds-card-content', 'is not followed by anyone') cy.get(".main-container").contains(
}) ".ds-card-content",
"is not followed by anyone"
);
});
Given("I wrote a post {string}", (title) => { Given("I wrote a post {string}", title => {
cy.factory() cy.factory()
.authenticateAs(loginCredentials) .authenticateAs(loginCredentials)
.create("Post", { title }) .create("Post", { title });
}) });
When("I block the user {string}", (name) => { When("I block the user {string}", name => {
cy.neode() cy.neode()
.first('User', { name }).then((blocked) => { .first("User", { name })
.then(blocked => {
cy.neode() cy.neode()
.first('User', {name: narratorParams.name}) .first("User", { name: narratorParams.name })
.relateTo(blocked, 'blocked') .relateTo(blocked, "blocked");
}) });
}) });
When("I log in with:", (table) => { When("I log in with:", table => {
const [firstRow] = table.hashes() const [firstRow] = table.hashes();
const { Email, Password } = firstRow const { Email, Password } = firstRow;
cy.login({email: Email, password: Password}) cy.login({ email: Email, password: Password });
}) });
Then("I see only one post with the title {string}", (title) => { Then("I see only one post with the title {string}", title => {
cy.get('.main-container').find('.post-link').should('have.length', 1) cy.get(".main-container")
cy.get('.main-container').contains('.post-link', title) .find(".post-link")
}) .should("have.length", 1);
cy.get(".main-container").contains(".post-link", title);
});

View File

@ -32,6 +32,8 @@
value: 1G value: 1G
- name: NEO4J_dbms_memory_heap_max__size - name: NEO4J_dbms_memory_heap_max__size
value: 1G value: 1G
- name: NEO4J_dbms_security_procedures_unrestricted
value: "algo.*,apoc.*"
envFrom: envFrom:
- configMapRef: - configMapRef:
name: configmap name: configmap

View File

@ -10,7 +10,7 @@
</ds-card> </ds-card>
</div> </div>
<div v-else :class="{ comment: true, 'disabled-content': comment.deleted || comment.disabled }"> <div v-else :class="{ comment: true, 'disabled-content': comment.deleted || comment.disabled }">
<ds-card> <ds-card :id="`commentId-${comment.id}`">
<ds-space margin-bottom="small"> <ds-space margin-bottom="small">
<hc-user :user="author" :date-time="comment.createdAt" /> <hc-user :user="author" :date-time="comment.createdAt" />
</ds-space> </ds-space>

View File

@ -24,9 +24,15 @@ describe('CommentForm.vue', () => {
mutate: jest mutate: jest
.fn() .fn()
.mockResolvedValueOnce({ .mockResolvedValueOnce({
data: { CreateComment: { contentExcerpt: 'this is a comment' } }, data: {
CreateComment: {
contentExcerpt: 'this is a comment',
},
},
}) })
.mockRejectedValue({ message: 'Ouch!' }), .mockRejectedValue({
message: 'Ouch!',
}),
}, },
$toast: { $toast: {
error: jest.fn(), error: jest.fn(),
@ -34,7 +40,9 @@ describe('CommentForm.vue', () => {
}, },
} }
propsData = { propsData = {
post: { id: 1 }, post: {
id: 1,
},
} }
}) })
@ -49,7 +57,12 @@ describe('CommentForm.vue', () => {
getters, getters,
}) })
const Wrapper = () => { const Wrapper = () => {
return mount(CommentForm, { mocks, localVue, propsData, store }) return mount(CommentForm, {
mocks,
localVue,
propsData,
store,
})
} }
beforeEach(() => { beforeEach(() => {

View File

@ -2,7 +2,13 @@
<ds-form v-show="!editPending" v-model="form" @submit="handleSubmit"> <ds-form v-show="!editPending" v-model="form" @submit="handleSubmit">
<template slot-scope="{ errors }"> <template slot-scope="{ errors }">
<ds-card> <ds-card>
<hc-editor ref="editor" :users="users" :value="form.content" @input="updateEditorContent" /> <hc-editor
ref="editor"
:users="users"
:hashtags="null"
: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' }">
<ds-flex-item :width="{ base: '0%', md: '50%', sm: '0%', xs: '0%' }" /> <ds-flex-item :width="{ base: '0%', md: '50%', sm: '0%', xs: '0%' }" />

View File

@ -16,6 +16,22 @@ describe('Editor.vue', () => {
let mocks let mocks
let getters let getters
const Wrapper = () => {
const store = new Vuex.Store({
getters,
})
return (wrapper = mount(Editor, {
mocks,
propsData,
localVue,
sync: false,
stubs: {
transition: false,
},
store,
}))
}
beforeEach(() => { beforeEach(() => {
propsData = {} propsData = {}
mocks = { mocks = {
@ -26,25 +42,10 @@ describe('Editor.vue', () => {
return 'some cool placeholder' return 'some cool placeholder'
}, },
} }
wrapper = Wrapper()
}) })
describe('mount', () => { describe('mount', () => {
let Wrapper = () => {
const store = new Vuex.Store({
getters,
})
return (wrapper = mount(Editor, {
mocks,
propsData,
localVue,
sync: false,
stubs: {
transition: false,
},
store,
}))
}
it('renders', () => { it('renders', () => {
expect(Wrapper().is('div')).toBe(true) expect(Wrapper().is('div')).toBe(true)
}) })
@ -67,5 +68,43 @@ describe('Editor.vue', () => {
) )
}) })
}) })
describe('optional extensions', () => {
it('sets the Mention items to the users', () => {
propsData.users = [
{
id: 'u345',
},
]
wrapper = Wrapper()
expect(wrapper.vm.editor.extensions.options.mention.items()).toEqual(propsData.users)
})
it('mentions is not an option when there are no users', () => {
expect(wrapper.vm.editor.extensions.options).toEqual(
expect.not.objectContaining({
mention: expect.anything(),
}),
)
})
it('sets the Hashtag items to the hashtags', () => {
propsData.hashtags = [
{
id: 'Frieden',
},
]
wrapper = Wrapper()
expect(wrapper.vm.editor.extensions.options.hashtag.items()).toEqual(propsData.hashtags)
})
it('hashtags is not an option when there are no hashtags', () => {
expect(wrapper.vm.editor.extensions.options).toEqual(
expect.not.objectContaining({
hashtag: expect.anything(),
}),
)
})
})
}) })
}) })

View File

@ -200,12 +200,173 @@ export default {
EditorMenuBubble, EditorMenuBubble,
}, },
props: { props: {
users: { type: Array, default: () => [] }, users: { type: Array, default: () => null }, // If 'null', than the Mention extention is not assigned.
hashtags: { type: Array, default: () => [] }, hashtags: { type: Array, default: () => null }, // If 'null', than the Hashtag extention is not assigned.
value: { type: String, default: '' }, value: { type: String, default: '' },
doc: { type: Object, default: () => {} }, doc: { type: Object, default: () => {} },
}, },
data() { data() {
// Set array of optional extensions by analysing the props.
let optionalExtensions = []
// Don't change the following line. The functionallity is in danger!
if (this.users) {
optionalExtensions.push(
new Mention({
// a list of all suggested items
items: () => {
return this.users
},
// is called when a suggestion starts
onEnter: ({ items, query, range, command, virtualNode }) => {
this.suggestionType = this.mentionSuggestionType
this.query = query
this.filteredItems = items
this.suggestionRange = range
this.renderPopup(virtualNode)
// we save the command for inserting a selected mention
// this allows us to call it inside of our custom popup
// via keyboard navigation and on click
this.insertMentionOrHashtag = command
},
// is called when a suggestion has changed
onChange: ({ items, query, range, virtualNode }) => {
this.query = query
this.filteredItems = items
this.suggestionRange = range
this.navigatedItemIndex = 0
this.renderPopup(virtualNode)
},
// is called when a suggestion is cancelled
onExit: () => {
this.suggestionType = this.nullSuggestionType
// reset all saved values
this.query = null
this.filteredItems = []
this.suggestionRange = null
this.navigatedItemIndex = 0
this.destroyPopup()
},
// is called on every keyDown event while a suggestion is active
onKeyDown: ({ event }) => {
// pressing up arrow
if (event.keyCode === 38) {
this.upHandler()
return true
}
// pressing down arrow
if (event.keyCode === 40) {
this.downHandler()
return true
}
// pressing enter
if (event.keyCode === 13) {
this.enterHandler()
return true
}
return false
},
// is called when a suggestion has changed
// this function is optional because there is basic filtering built-in
// you can overwrite it if you prefer your own filtering
// in this example we use fuse.js with support for fuzzy search
onFilter: (items, query) => {
if (!query) {
return items
}
const fuse = new Fuse(items, {
threshold: 0.2,
keys: ['slug'],
})
return fuse.search(query)
},
}),
)
}
// Don't change the following line. The functionallity is in danger!
if (this.hashtags) {
optionalExtensions.push(
new Hashtag({
// a list of all suggested items
items: () => {
return this.hashtags
},
// is called when a suggestion starts
onEnter: ({ items, query, range, command, virtualNode }) => {
this.suggestionType = this.hashtagSuggestionType
this.query = this.sanitizedQuery(query)
this.filteredItems = items
this.suggestionRange = range
this.renderPopup(virtualNode)
// we save the command for inserting a selected mention
// this allows us to call it inside of our custom popup
// via keyboard navigation and on click
this.insertMentionOrHashtag = command
},
// is called when a suggestion has changed
onChange: ({ items, query, range, virtualNode }) => {
this.query = this.sanitizedQuery(query)
this.filteredItems = items
this.suggestionRange = range
this.navigatedItemIndex = 0
this.renderPopup(virtualNode)
},
// is called when a suggestion is cancelled
onExit: () => {
this.suggestionType = this.nullSuggestionType
// reset all saved values
this.query = null
this.filteredItems = []
this.suggestionRange = null
this.navigatedItemIndex = 0
this.destroyPopup()
},
// is called on every keyDown event while a suggestion is active
onKeyDown: ({ event }) => {
// pressing up arrow
if (event.keyCode === 38) {
this.upHandler()
return true
}
// pressing down arrow
if (event.keyCode === 40) {
this.downHandler()
return true
}
// pressing enter
if (event.keyCode === 13) {
this.enterHandler()
return true
}
// pressing space
if (event.keyCode === 32) {
this.spaceHandler()
return true
}
return false
},
// is called when a suggestion has changed
// this function is optional because there is basic filtering built-in
// you can overwrite it if you prefer your own filtering
// in this example we use fuse.js with support for fuzzy search
onFilter: (items, query) => {
query = this.sanitizedQuery(query)
if (!query) {
return items
}
return items.filter(item =>
JSON.stringify(item)
.toLowerCase()
.includes(query.toLowerCase()),
)
},
}),
)
}
return { return {
lastValueHash: null, lastValueHash: null,
editor: new Editor({ editor: new Editor({
@ -215,154 +376,7 @@ export default {
...defaultExtensions(this), ...defaultExtensions(this),
new EventHandler(), new EventHandler(),
new History(), new History(),
new Mention({ ...optionalExtensions,
// a list of all suggested items
items: () => {
return this.users
},
// is called when a suggestion starts
onEnter: ({ items, query, range, command, virtualNode }) => {
this.suggestionType = this.mentionSuggestionType
this.query = query
this.filteredItems = items
this.suggestionRange = range
this.renderPopup(virtualNode)
// we save the command for inserting a selected mention
// this allows us to call it inside of our custom popup
// via keyboard navigation and on click
this.insertMentionOrHashtag = command
},
// is called when a suggestion has changed
onChange: ({ items, query, range, virtualNode }) => {
this.query = query
this.filteredItems = items
this.suggestionRange = range
this.navigatedItemIndex = 0
this.renderPopup(virtualNode)
},
// is called when a suggestion is cancelled
onExit: () => {
this.suggestionType = this.nullSuggestionType
// reset all saved values
this.query = null
this.filteredItems = []
this.suggestionRange = null
this.navigatedItemIndex = 0
this.destroyPopup()
},
// is called on every keyDown event while a suggestion is active
onKeyDown: ({ event }) => {
// pressing up arrow
if (event.keyCode === 38) {
this.upHandler()
return true
}
// pressing down arrow
if (event.keyCode === 40) {
this.downHandler()
return true
}
// pressing enter
if (event.keyCode === 13) {
this.enterHandler()
return true
}
return false
},
// is called when a suggestion has changed
// this function is optional because there is basic filtering built-in
// you can overwrite it if you prefer your own filtering
// in this example we use fuse.js with support for fuzzy search
onFilter: (items, query) => {
if (!query) {
return items
}
const fuse = new Fuse(items, {
threshold: 0.2,
keys: ['slug'],
})
return fuse.search(query)
},
}),
new Hashtag({
// a list of all suggested items
items: () => {
return this.hashtags
},
// is called when a suggestion starts
onEnter: ({ items, query, range, command, virtualNode }) => {
this.suggestionType = this.hashtagSuggestionType
this.query = this.sanitizedQuery(query)
this.filteredItems = items
this.suggestionRange = range
this.renderPopup(virtualNode)
// we save the command for inserting a selected mention
// this allows us to call it inside of our custom popup
// via keyboard navigation and on click
this.insertMentionOrHashtag = command
},
// is called when a suggestion has changed
onChange: ({ items, query, range, virtualNode }) => {
this.query = this.sanitizedQuery(query)
this.filteredItems = items
this.suggestionRange = range
this.navigatedItemIndex = 0
this.renderPopup(virtualNode)
},
// is called when a suggestion is cancelled
onExit: () => {
this.suggestionType = this.nullSuggestionType
// reset all saved values
this.query = null
this.filteredItems = []
this.suggestionRange = null
this.navigatedItemIndex = 0
this.destroyPopup()
},
// is called on every keyDown event while a suggestion is active
onKeyDown: ({ event }) => {
// pressing up arrow
if (event.keyCode === 38) {
this.upHandler()
return true
}
// pressing down arrow
if (event.keyCode === 40) {
this.downHandler()
return true
}
// pressing enter
if (event.keyCode === 13) {
this.enterHandler()
return true
}
// pressing space
if (event.keyCode === 32) {
this.spaceHandler()
return true
}
return false
},
// is called when a suggestion has changed
// this function is optional because there is basic filtering built-in
// you can overwrite it if you prefer your own filtering
// in this example we use fuse.js with support for fuzzy search
onFilter: (items, query) => {
query = this.sanitizedQuery(query)
if (!query) {
return items
}
return items.filter(item =>
JSON.stringify(item)
.toLowerCase()
.includes(query.toLowerCase()),
)
},
}),
], ],
onUpdate: e => { onUpdate: e => {
clearTimeout(throttleInputEvent) clearTimeout(throttleInputEvent)

View File

@ -1,5 +1,5 @@
import { config, mount, createLocalVue, RouterLinkStub } from '@vue/test-utils' import { config, mount, createLocalVue, RouterLinkStub } from '@vue/test-utils'
import Notification from '.' import Notification from './Notification'
import Styleguide from '@human-connection/styleguide' import Styleguide from '@human-connection/styleguide'
import Filters from '~/plugins/vue-filters' import Filters from '~/plugins/vue-filters'
@ -38,6 +38,9 @@ describe('Notification', () => {
propsData.notification = { propsData.notification = {
post: { post: {
title: "It's a title", title: "It's a title",
id: 'post-1',
slug: 'its-a-title',
contentExcerpt: '<a href="/profile/u3" target="_blank">@jenny-rostock</a> is the best',
}, },
} }
}) })
@ -46,6 +49,10 @@ describe('Notification', () => {
expect(Wrapper().text()).toContain("It's a title") expect(Wrapper().text()).toContain("It's a title")
}) })
it('renders the contentExcerpt', () => {
expect(Wrapper().text()).toContain('@jenny-rostock is the best')
})
it('has no class "read"', () => { it('has no class "read"', () => {
expect(Wrapper().classes()).not.toContain('read') expect(Wrapper().classes()).not.toContain('read')
}) })

View File

@ -0,0 +1,91 @@
<template>
<ds-space :class="[{ read: notification.read }, notification]" margin-bottom="x-small">
<no-ssr>
<ds-space margin-bottom="x-small">
<hc-user
v-if="resourceType == 'Post'"
:user="post.author"
:date-time="post.createdAt"
:trunc="35"
/>
<hc-user v-else :user="comment.author" :date-time="comment.createdAt" :trunc="35" />
</ds-space>
<ds-text color="soft">
{{ $t('notifications.menu.mentioned', { resource: resourceType }) }}
</ds-text>
</no-ssr>
<ds-space margin-bottom="x-small" />
<nuxt-link
class="notification-mention-post"
:to="{ name: 'post-id-slug', params, ...hashParam }"
@click.native="$emit('read')"
>
<ds-space margin-bottom="x-small">
<ds-card
:header="post.title || comment.post.title"
hover
space="x-small"
class="notifications-card"
>
<ds-space margin-bottom="x-small" />
<div v-if="resourceType == 'Post'">{{ post.contentExcerpt | removeHtml }}</div>
<div v-else>
<span class="comment-notification-header">Comment:</span>
{{ comment.contentExcerpt | removeHtml }}
</div>
</ds-card>
</ds-space>
</nuxt-link>
</ds-space>
</template>
<script>
import HcUser from '~/components/User'
export default {
name: 'Notification',
components: {
HcUser,
},
props: {
notification: {
type: Object,
required: true,
},
},
computed: {
resourceType() {
return this.post.id ? 'Post' : 'Comment'
},
post() {
return this.notification.post || {}
},
comment() {
return this.notification.comment || {}
},
params() {
return {
id: this.post.id || this.comment.post.id,
slug: this.post.slug || this.comment.post.slug,
}
},
hashParam() {
return this.post.id ? {} : { hash: `#commentId-${this.comment.id}` }
},
},
}
</script>
<style>
.notification.read {
opacity: 0.6; /* Real browsers */
filter: alpha(opacity = 60); /* MSIE */
}
.notifications-card {
min-width: 500px;
}
.comment-notification-header {
font-weight: 700;
margin-right: 0.1rem;
}
</style>

View File

@ -1,59 +0,0 @@
<template>
<ds-space :class="{ notification: true, read: notification.read }" margin-bottom="x-small">
<no-ssr>
<ds-space margin-bottom="x-small">
<hc-user :user="post.author" :date-time="post.createdAt" :trunc="35" />
</ds-space>
<ds-text color="soft">
{{ $t('notifications.menu.mentioned') }}
</ds-text>
</no-ssr>
<ds-space margin-bottom="x-small" />
<nuxt-link
class="notification-mention-post"
:to="{ name: 'post-id-slug', params: { id: post.id, slug: post.slug } }"
@click.native="$emit('read')"
>
<ds-space margin-bottom="x-small">
<ds-card :header="post.title" :image="post.image" hover space="x-small">
<ds-space margin-bottom="x-small" />
<!-- eslint-disable vue/no-v-html -->
<div v-html="excerpt" />
<!-- eslint-enable vue/no-v-html -->
</ds-card>
</ds-space>
</nuxt-link>
</ds-space>
</template>
<script>
import HcUser from '~/components/User'
export default {
name: 'Notification',
components: {
HcUser,
},
props: {
notification: {
type: Object,
required: true,
},
},
computed: {
excerpt() {
return this.$filters.removeLinks(this.post.contentExcerpt)
},
post() {
return this.notification.post || {}
},
},
}
</script>
<style>
.notification.read {
opacity: 0.6; /* Real browsers */
filter: alpha(opacity = 60); /* MSIE */
}
</style>

View File

@ -1,6 +1,6 @@
import { config, shallowMount, mount, createLocalVue, RouterLinkStub } from '@vue/test-utils' import { config, shallowMount, mount, createLocalVue, RouterLinkStub } from '@vue/test-utils'
import NotificationList from '.' import NotificationList from './NotificationList'
import Notification from '../Notification' import Notification from '../Notification/Notification'
import Vuex from 'vuex' import Vuex from 'vuex'
import Filters from '~/plugins/vue-filters' import Filters from '~/plugins/vue-filters'
@ -45,6 +45,7 @@ describe('NotificationList.vue', () => {
post: { post: {
id: 'post-1', id: 'post-1',
title: 'some post title', title: 'some post title',
slug: 'some-post-title',
contentExcerpt: 'this is a post content', contentExcerpt: 'this is a post content',
author: { author: {
id: 'john-1', id: 'john-1',
@ -59,6 +60,7 @@ describe('NotificationList.vue', () => {
post: { post: {
id: 'post-2', id: 'post-2',
title: 'another post title', title: 'another post title',
slug: 'another-post-title',
contentExcerpt: 'this is yet another post content', contentExcerpt: 'this is yet another post content',
author: { author: {
id: 'john-1', id: 'john-1',

View File

@ -10,7 +10,7 @@
</template> </template>
<script> <script>
import Notification from '../Notification' import Notification from '../Notification/Notification'
export default { export default {
name: 'NotificationList', name: 'NotificationList',

View File

@ -1,5 +1,5 @@
import { config, shallowMount, createLocalVue } from '@vue/test-utils' import { config, shallowMount, createLocalVue } from '@vue/test-utils'
import NotificationMenu from '.' import NotificationMenu from './NotificationMenu'
import Styleguide from '@human-connection/styleguide' import Styleguide from '@human-connection/styleguide'
import Filters from '~/plugins/vue-filters' import Filters from '~/plugins/vue-filters'

View File

@ -17,30 +17,9 @@
</template> </template>
<script> <script>
import NotificationList from '../NotificationList'
import Dropdown from '~/components/Dropdown' import Dropdown from '~/components/Dropdown'
import gql from 'graphql-tag' import { currentUserNotificationsQuery, updateNotificationMutation } from '~/graphql/User'
import NotificationList from '../NotificationList/NotificationList'
const MARK_AS_READ = gql(`
mutation($id: ID!, $read: Boolean!) {
UpdateNotification(id: $id, read: $read) {
id
read
}
}`)
const NOTIFICATIONS = gql(`{
currentUser {
id
notifications(read: false, orderBy: createdAt_desc) {
id read createdAt
post {
id createdAt disabled deleted title contentExcerpt slug
author { id slug name disabled deleted }
}
}
}
}`)
export default { export default {
name: 'NotificationMenu', name: 'NotificationMenu',
@ -61,7 +40,7 @@ export default {
const variables = { id: notificationId, read: true } const variables = { id: notificationId, read: true }
try { try {
await this.$apollo.mutate({ await this.$apollo.mutate({
mutation: MARK_AS_READ, mutation: updateNotificationMutation(),
variables, variables,
}) })
} catch (err) { } catch (err) {
@ -71,7 +50,7 @@ export default {
}, },
apollo: { apollo: {
notifications: { notifications: {
query: NOTIFICATIONS, query: currentUserNotificationsQuery(),
update: data => { update: data => {
const { const {
currentUser: { notifications }, currentUser: { notifications },

View File

@ -74,3 +74,78 @@ export default i18n => {
} }
` `
} }
export const currentUserNotificationsQuery = () => {
return gql`
{
currentUser {
id
notifications(read: false, orderBy: createdAt_desc) {
id
read
createdAt
post {
id
createdAt
disabled
deleted
title
contentExcerpt
slug
author {
id
slug
name
disabled
deleted
avatar
}
}
comment {
id
createdAt
disabled
deleted
contentExcerpt
author {
id
slug
name
disabled
deleted
avatar
}
post {
id
createdAt
disabled
deleted
title
contentExcerpt
slug
author {
id
slug
name
disabled
deleted
avatar
}
}
}
}
}
}
`
}
export const updateNotificationMutation = () => {
return gql`
mutation($id: ID!, $read: Boolean!) {
UpdateNotification(id: $id, read: $read) {
id
read
}
}
`
}

View File

@ -151,7 +151,7 @@ import { mapGetters, mapActions, mapMutations } from 'vuex'
import LocaleSwitch from '~/components/LocaleSwitch/LocaleSwitch' import LocaleSwitch from '~/components/LocaleSwitch/LocaleSwitch'
import SearchInput from '~/components/SearchInput.vue' import SearchInput from '~/components/SearchInput.vue'
import Modal from '~/components/Modal' import Modal from '~/components/Modal'
import NotificationMenu from '~/components/notifications/NotificationMenu' import NotificationMenu from '~/components/notifications/NotificationMenu/NotificationMenu'
import Dropdown from '~/components/Dropdown' import Dropdown from '~/components/Dropdown'
import HcAvatar from '~/components/Avatar/Avatar.vue' import HcAvatar from '~/components/Avatar/Avatar.vue'
import seo from '~/mixins/seo' import seo from '~/mixins/seo'

View File

@ -124,7 +124,7 @@
}, },
"notifications": { "notifications": {
"menu": { "menu": {
"mentioned": "hat dich in einem Beitrag erwähnt" "mentioned": "hat dich in einem {resource} erwähnt"
} }
}, },
"search": { "search": {

View File

@ -124,7 +124,7 @@
}, },
"notifications": { "notifications": {
"menu": { "menu": {
"mentioned": "mentioned you in a post" "mentioned": "mentioned you in a {resource}"
} }
}, },
"search": { "search": {

View File

@ -57,11 +57,26 @@ module.exports = {
title: 'Human Connection', title: 'Human Connection',
titleTemplate: '%s - Human Connection', titleTemplate: '%s - Human Connection',
meta: [ meta: [
{ charset: 'utf-8' }, {
{ name: 'viewport', content: 'width=device-width, initial-scale=1' }, charset: 'utf-8',
{ hid: 'description', name: 'description', content: pkg.description }, },
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{
hid: 'description',
name: 'description',
content: pkg.description,
},
],
link: [
{
rel: 'icon',
type: 'image/x-icon',
href: '/favicon.ico',
},
], ],
link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }],
}, },
/* /*
@ -107,8 +122,79 @@ module.exports = {
middleware: ['authenticated'], middleware: ['authenticated'],
linkActiveClass: 'router-link-active', linkActiveClass: 'router-link-active',
linkExactActiveClass: 'router-link-exact-active', linkExactActiveClass: 'router-link-exact-active',
scrollBehavior: () => { scrollBehavior: (to, _from, savedPosition) => {
return { x: 0, y: 0 } let position = false
// if no children detected and scrollToTop is not explicitly disabled
if (
to.matched.length < 2 &&
to.matched.every(r => r.components.default.options.scrollToTop !== false)
) {
// scroll to the top of the page
position = {
x: 0,
y: 0,
}
} else if (to.matched.some(r => r.components.default.options.scrollToTop)) {
// if one of the children has scrollToTop option set to true
position = {
x: 0,
y: 0,
}
}
// savedPosition is only available for popstate navigations (back button)
if (savedPosition) {
position = savedPosition
}
return new Promise(resolve => {
// wait for the out transition to complete (if necessary)
window.$nuxt.$once('triggerScroll', () => {
let processInterval = null
let processTime = 0
const callInterval = 100
const callIntervalLimit = 2000
// coords will be used if no selector is provided,
// or if the selector didn't match any element.
if (to.hash) {
let hash = to.hash
// CSS.escape() is not supported with IE and Edge.
if (typeof window.CSS !== 'undefined' && typeof window.CSS.escape !== 'undefined') {
hash = '#' + window.CSS.escape(hash.substr(1))
}
try {
processInterval = setInterval(() => {
const hashIsFound = document.querySelector(hash)
if (hashIsFound) {
position = {
selector: hash,
offset: { x: 0, y: -500 },
}
}
processTime += callInterval
if (hashIsFound || processTime >= callIntervalLimit) {
clearInterval(processInterval)
processInterval = null
}
}, callInterval)
} catch (e) {
/* eslint-disable-next-line no-console */
console.warn(
'Failed to save scroll position. Please add CSS.escape() polyfill (https://github.com/mathiasbynens/CSS.escape).',
)
}
}
let resolveInterval = setInterval(() => {
if (!processInterval) {
clearInterval(resolveInterval)
resolve(position)
}
}, callInterval)
})
})
}, },
}, },
@ -116,8 +202,18 @@ module.exports = {
** Nuxt.js modules ** Nuxt.js modules
*/ */
modules: [ modules: [
['@nuxtjs/dotenv', { only: envWhitelist }], [
['nuxt-env', { keys: envWhitelist }], '@nuxtjs/dotenv',
{
only: envWhitelist,
},
],
[
'nuxt-env',
{
keys: envWhitelist,
},
],
'cookie-universal-nuxt', 'cookie-universal-nuxt',
'@nuxtjs/apollo', '@nuxtjs/apollo',
'@nuxtjs/axios', '@nuxtjs/axios',
@ -155,7 +251,9 @@ module.exports = {
'/api': { '/api': {
// make this configurable (nuxt-dotenv) // make this configurable (nuxt-dotenv)
target: process.env.GRAPHQL_URI || 'http://localhost:4000', target: process.env.GRAPHQL_URI || 'http://localhost:4000',
pathRewrite: { '^/api': '' }, pathRewrite: {
'^/api': '',
},
toProxy: true, // cloudflare needs that toProxy: true, // cloudflare needs that
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',

View File

@ -101,7 +101,7 @@
"core-js": "~2.6.9", "core-js": "~2.6.9",
"css-loader": "~2.1.1", "css-loader": "~2.1.1",
"eslint": "~5.16.0", "eslint": "~5.16.0",
"eslint-config-prettier": "~6.0.0", "eslint-config-prettier": "~6.1.0",
"eslint-config-standard": "~12.0.0", "eslint-config-standard": "~12.0.0",
"eslint-loader": "~2.2.1", "eslint-loader": "~2.2.1",
"eslint-plugin-import": "~2.18.2", "eslint-plugin-import": "~2.18.2",
@ -109,7 +109,7 @@
"eslint-plugin-node": "~9.1.0", "eslint-plugin-node": "~9.1.0",
"eslint-plugin-prettier": "~3.1.0", "eslint-plugin-prettier": "~3.1.0",
"eslint-plugin-promise": "~4.2.1", "eslint-plugin-promise": "~4.2.1",
"eslint-plugin-standard": "~4.0.0", "eslint-plugin-standard": "~4.0.1",
"eslint-plugin-vue": "~5.2.3", "eslint-plugin-vue": "~5.2.3",
"flush-promises": "^1.0.2", "flush-promises": "^1.0.2",
"fuse.js": "^3.4.5", "fuse.js": "^3.4.5",

View File

@ -83,6 +83,15 @@ export default ({ app = {} }) => {
return excerpt return excerpt
}, },
removeHtml: content => {
if (!content) return ''
// replace linebreaks with spaces first
let contentExcerpt = content.replace(/<br>/gim, ' ').trim()
// remove the rest of the HTML
contentExcerpt = contentExcerpt.replace(/<(?:.|\n)*?>/gm, '').trim()
return contentExcerpt
},
proxyApiUrl: url => { proxyApiUrl: url => {
if (!url) return url if (!url) return url
return url.startsWith('/') ? url.replace('/', '/api/') : url return url.startsWith('/') ? url.replace('/', '/api/') : url

View File

@ -69,41 +69,43 @@ export const actions = {
const { const {
data: { currentUser }, data: { currentUser },
} = await client.query({ } = await client.query({
query: gql(`{ query: gql`
currentUser { query {
id currentUser {
name
slug
email
avatar
role
about
locationName
contributionsCount
commentedCount
socialMedia {
id id
url name
} slug
notifications(read: false, orderBy: createdAt_desc) { email
id avatar
read role
createdAt about
post { locationName
author { contributionsCount
id commentedCount
socialMedia {
id
url
}
notifications(read: false, orderBy: createdAt_desc) {
id
read
createdAt
post {
author {
id
slug
name
disabled
deleted
}
title
contentExcerpt
slug slug
name
disabled
deleted
} }
title
contentExcerpt
slug
} }
} }
} }
}`), `,
}) })
if (!currentUser) return dispatch('logout') if (!currentUser) return dispatch('logout')
commit('SET_USER', currentUser) commit('SET_USER', currentUser)
@ -122,7 +124,10 @@ export const actions = {
login(email: $email, password: $password) login(email: $email, password: $password)
} }
`), `),
variables: { email, password }, variables: {
email,
password,
},
}) })
await this.app.$apolloHelpers.onLogin(login) await this.app.$apolloHelpers.onLogin(login)
commit('SET_TOKEN', login) commit('SET_TOKEN', login)

View File

@ -19,7 +19,10 @@ export const mutations = {
const toBeUpdated = notifications.find(n => { const toBeUpdated = notifications.find(n => {
return n.id === notification.id return n.id === notification.id
}) })
state.notifications = { ...toBeUpdated, ...notification } state.notifications = {
...toBeUpdated,
...notification,
}
}, },
} }
export const getters = { export const getters = {
@ -38,28 +41,30 @@ export const actions = {
const { const {
data: { currentUser }, data: { currentUser },
} = await client.query({ } = await client.query({
query: gql(`{ query: gql`
currentUser { {
id currentUser {
notifications(orderBy: createdAt_desc) {
id id
read notifications(orderBy: createdAt_desc) {
createdAt id
post { read
author { createdAt
id post {
author {
id
slug
name
disabled
deleted
}
title
contentExcerpt
slug slug
name
disabled
deleted
} }
title
contentExcerpt
slug
} }
} }
} }
}`), `,
}) })
notifications = currentUser.notifications notifications = currentUser.notifications
commit('SET_NOTIFICATIONS', notifications) commit('SET_NOTIFICATIONS', notifications)
@ -71,18 +76,24 @@ export const actions = {
async markAsRead({ commit, rootGetters }, notificationId) { async markAsRead({ commit, rootGetters }, notificationId) {
const client = this.app.apolloProvider.defaultClient const client = this.app.apolloProvider.defaultClient
const mutation = gql(` const mutation = gql`
mutation($id: ID!, $read: Boolean!) { mutation($id: ID!, $read: Boolean!) {
UpdateNotification(id: $id, read: $read) { UpdateNotification(id: $id, read: $read) {
id id
read read
} }
} }
`) `
const variables = { id: notificationId, read: true } const variables = {
id: notificationId,
read: true,
}
const { const {
data: { UpdateNotification }, data: { UpdateNotification },
} = await client.mutate({ mutation, variables }) } = await client.mutate({
mutation,
variables,
})
commit('UPDATE_NOTIFICATIONS', UpdateNotification) commit('UPDATE_NOTIFICATIONS', UpdateNotification)
}, },
} }

View File

@ -6168,10 +6168,10 @@ escodegen@^1.9.1:
optionalDependencies: optionalDependencies:
source-map "~0.6.1" source-map "~0.6.1"
eslint-config-prettier@^6.0.0, eslint-config-prettier@~6.0.0: eslint-config-prettier@^6.0.0, eslint-config-prettier@~6.1.0:
version "6.0.0" version "6.1.0"
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-6.0.0.tgz#f429a53bde9fc7660e6353910fd996d6284d3c25" resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-6.1.0.tgz#e6f678ba367fbd1273998d5510f76f004e9dce7b"
integrity sha512-vDrcCFE3+2ixNT5H83g28bO/uYAwibJxerXPj+E7op4qzBCsAV36QfvdAyVOoNxKAH2Os/e01T/2x++V0LPukA== integrity sha512-k9fny9sPjIBQ2ftFTesJV21Rg4R/7a7t7LCtZVrYQiHEp8Nnuk3EGaDmsKSAnsPj0BYcgB2zxzHa2NTkIxcOLg==
dependencies: dependencies:
get-stdin "^6.0.0" get-stdin "^6.0.0"
@ -6263,10 +6263,10 @@ eslint-plugin-promise@~4.2.1:
resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-4.2.1.tgz#845fd8b2260ad8f82564c1222fce44ad71d9418a" resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-4.2.1.tgz#845fd8b2260ad8f82564c1222fce44ad71d9418a"
integrity sha512-VoM09vT7bfA7D+upt+FjeBO5eHIJQBUWki1aPvB+vbNiHS3+oGIJGIeyBtKQTME6UPXXy3vV07OL1tHd3ANuDw== integrity sha512-VoM09vT7bfA7D+upt+FjeBO5eHIJQBUWki1aPvB+vbNiHS3+oGIJGIeyBtKQTME6UPXXy3vV07OL1tHd3ANuDw==
eslint-plugin-standard@~4.0.0: eslint-plugin-standard@~4.0.1:
version "4.0.0" version "4.0.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-standard/-/eslint-plugin-standard-4.0.0.tgz#f845b45109c99cd90e77796940a344546c8f6b5c" resolved "https://registry.yarnpkg.com/eslint-plugin-standard/-/eslint-plugin-standard-4.0.1.tgz#ff0519f7ffaff114f76d1bd7c3996eef0f6e20b4"
integrity sha512-OwxJkR6TQiYMmt1EsNRMe5qG3GsbjlcOhbGUBY4LtavF9DsLaTcoR+j2Tdjqi23oUwKNUqX7qcn5fPStafMdlA== integrity sha512-v/KBnfyaOMPmZc/dmc6ozOdWqekGp7bBGq4jLAecEfPGmfKiWS4sA8sC0LqiV9w5qmXAtXVn4M3p1jSyhY85SQ==
eslint-plugin-vue@~5.2.3: eslint-plugin-vue@~5.2.3:
version "5.2.3" version "5.2.3"