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

View File

@ -1,39 +1,57 @@
import extractMentionedUsers from './notifications/extractMentionedUsers'
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 createdAt = new Date().toISOString()
const cypher = `
MATCH(p:Post {id: $postId})<-[:WROTE]-(author:User)
MATCH(u:User)
WHERE u.id in $idsOfMentionedUsers
AND NOT (u)<-[:BLOCKED]-(author)
CREATE(n:Notification{id: apoc.create.uuid(), read: false, createdAt: $createdAt})
MERGE (p)-[:NOTIFIED]->(n)-[:NOTIFIED]->(u)
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,
postId,
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)
MATCH (p: Post { id: $postId })-[previousRelations: TAGGED]->(t: Tag)
DELETE previousRelations
RETURN p, t
`
const cypherCreateNewTagsAndRelations = `
MATCH (p:Post { id: $postId})
MATCH (p: Post { id: $postId})
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)
RETURN p, t
`
@ -47,24 +65,32 @@ const updateHashtagsOfPost = async (postId, hashtags, context) => {
session.close()
}
const handleContentData = async (resolve, root, args, context, resolveInfo) => {
// extract user ids before xss-middleware removes classes via the following "resolve" call
const handleContentDataOfPost = async (resolve, root, args, context, resolveInfo) => {
const idsOfMentionedUsers = extractMentionedUsers(args.content)
// extract tag (hashtag) ids before xss-middleware removes classes via the following "resolve" call
const hashtags = extractHashtags(args.content)
// removes classes from the content
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)
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: handleContentData,
UpdatePost: handleContentData,
CreatePost: handleContentDataOfPost,
UpdatePost: handleContentDataOfPost,
CreateComment: handleContentDataOfComment,
UpdateComment: handleContentDataOfComment,
},
}

View File

@ -75,6 +75,9 @@ describe('notifications', () => {
post {
content
}
comment {
content
}
}
}
}
@ -86,12 +89,12 @@ describe('notifications', () => {
})
describe('given another user', () => {
let author
let postAuthor
beforeEach(async () => {
author = await instance.create('User', {
email: 'author@example.org',
postAuthor = await instance.create('User', {
email: 'post-author@example.org',
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?'
const createPostAction = async () => {
authenticatedUser = await author.toJson()
authenticatedUser = await postAuthor.toJson()
await mutate({
mutation: createPostMutation,
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?'
const expected = expect.objectContaining({
data: {
currentUser: { notifications: [{ read: false, post: { content: expectedContent } }] },
currentUser: {
notifications: [
{
read: false,
post: {
content: expectedContent,
},
comment: null,
},
],
},
},
})
const { query } = createTestClient(server)
await expect(
query({ query: notificationQuery, variables: { read: false } }),
query({
query: notificationQuery,
variables: {
read: false,
},
}),
).resolves.toEqual(expected)
})
@ -140,7 +158,7 @@ describe('notifications', () => {
@al-capone
</a>
`
authenticatedUser = await author.toJson()
authenticatedUser = await postAuthor.toJson()
await mutate({
mutation: updatePostMutation,
variables: {
@ -162,31 +180,114 @@ describe('notifications', () => {
data: {
currentUser: {
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(
query({ query: notificationQuery, variables: { read: false } }),
query({
query: notificationQuery,
variables: {
read: false,
},
}),
).resolves.toEqual(expected)
})
})
describe('but the author of the post blocked me', () => {
beforeEach(async () => {
await author.relateTo(user, 'blocked')
await postAuthor.relateTo(user, 'blocked')
})
it('sends no notification', async () => {
await createPostAction()
const expected = expect.objectContaining({
data: { currentUser: { notifications: [] } },
data: {
currentUser: {
notifications: [],
},
},
})
const { query } = createTestClient(server)
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)
})
})
@ -234,7 +335,10 @@ describe('Hashtags', () => {
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 }),
query({
query: postWithHastagsQuery,
variables: postWithHastagsVariables,
}),
).resolves.toEqual(
expect.objectContaining({
data: {
@ -266,11 +370,18 @@ describe('Hashtags', () => {
const expected = [{ id: 'Elections' }, { id: 'Liberty' }]
await expect(
query({ query: postWithHastagsQuery, variables: postWithHastagsVariables }),
query({
query: postWithHastagsQuery,
variables: postWithHastagsVariables,
}),
).resolves.toEqual(
expect.objectContaining({
data: {
Post: [{ tags: expect.arrayContaining(expected) }],
Post: [
{
tags: expect.arrayContaining(expected),
},
],
},
}),
)

View File

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

View File

@ -3,5 +3,6 @@ type Notification {
read: Boolean
user: User @relation(name: "NOTIFIED", direction: "OUT")
post: Post @relation(name: "NOTIFIED", direction: "IN")
comment: Comment @relation(name: "NOTIFIED", direction: "IN")
createdAt: String
}

View File

@ -44,42 +44,49 @@ import Factory from './factories'
f.create('User', {
id: 'u1',
name: 'Peter Lustig',
slug: 'peter-lustig',
role: 'admin',
email: 'admin@example.org',
}),
f.create('User', {
id: 'u2',
name: 'Bob der Baumeister',
slug: 'bob-der-baumeister',
role: 'moderator',
email: 'moderator@example.org',
}),
f.create('User', {
id: 'u3',
name: 'Jenny Rostock',
slug: 'jenny-rostock',
role: 'user',
email: 'user@example.org',
}),
f.create('User', {
id: 'u4',
name: 'Tick',
name: 'Huey (Tick)',
slug: 'huey-tick',
role: 'user',
email: 'tick@example.org',
email: 'huey@example.org',
}),
f.create('User', {
id: 'u5',
name: 'Trick',
name: 'Dewey (Trick)',
slug: 'dewey-trick',
role: 'user',
email: 'trick@example.org',
email: 'dewey@example.org',
}),
f.create('User', {
id: 'u6',
name: 'Track',
name: 'Louie (Track)',
slug: 'louie-track',
role: 'user',
email: 'track@example.org',
email: 'louie@example.org',
}),
f.create('User', {
id: 'u7',
name: 'Dagobert',
slug: 'dagobert',
role: 'user',
email: 'dagobert@example.org',
}),
@ -99,15 +106,15 @@ import Factory from './factories'
password: '1234',
}),
Factory().authenticateAs({
email: 'tick@example.org',
email: 'huey@example.org',
password: '1234',
}),
Factory().authenticateAs({
email: 'trick@example.org',
email: 'dewey@example.org',
password: '1234',
}),
Factory().authenticateAs({
email: 'track@example.org',
email: 'louie@example.org',
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?'
const mention2 =
'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([
asAdmin.create('Post', {
@ -272,6 +283,8 @@ import Factory from './factories'
}),
asUser.create('Post', {
id: 'p2',
title: `Nature Philosophy Yoga`,
content: `${hashtag1}`,
}),
asTick.create('Post', {
id: 'p3',
@ -293,6 +306,8 @@ import Factory from './factories'
asUser.create('Post', {
id: 'p8',
image: faker.image.unsplash.nature(),
title: `Quantum Flow Theory explains Quantum Gravity`,
content: `${hashtagAndMention1}`,
}),
asTick.create('Post', {
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([
asUser.create('Comment', {
id: 'c1',
@ -655,6 +675,12 @@ import Factory from './factories'
asTrick.create('Comment', {
id: 'c4',
postId: 'p2',
content: `${mentionInComment1}`,
}),
asUser.create('Comment', {
id: 'c4-1',
postId: 'p2',
content: `${mentionInComment2}`,
}),
asModerator.create('Comment', {
id: 'c5',

View File

@ -3267,10 +3267,10 @@ escodegen@^1.9.1:
optionalDependencies:
source-map "~0.6.1"
eslint-config-prettier@~6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-6.0.0.tgz#f429a53bde9fc7660e6353910fd996d6284d3c25"
integrity sha512-vDrcCFE3+2ixNT5H83g28bO/uYAwibJxerXPj+E7op4qzBCsAV36QfvdAyVOoNxKAH2Os/e01T/2x++V0LPukA==
eslint-config-prettier@~6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-6.1.0.tgz#e6f678ba367fbd1273998d5510f76f004e9dce7b"
integrity sha512-k9fny9sPjIBQ2ftFTesJV21Rg4R/7a7t7LCtZVrYQiHEp8Nnuk3EGaDmsKSAnsPj0BYcgB2zxzHa2NTkIxcOLg==
dependencies:
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"
integrity sha512-VoM09vT7bfA7D+upt+FjeBO5eHIJQBUWki1aPvB+vbNiHS3+oGIJGIeyBtKQTME6UPXXy3vV07OL1tHd3ANuDw==
eslint-plugin-standard@~4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-standard/-/eslint-plugin-standard-4.0.0.tgz#f845b45109c99cd90e77796940a344546c8f6b5c"
integrity sha512-OwxJkR6TQiYMmt1EsNRMe5qG3GsbjlcOhbGUBY4LtavF9DsLaTcoR+j2Tdjqi23oUwKNUqX7qcn5fPStafMdlA==
eslint-plugin-standard@~4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-standard/-/eslint-plugin-standard-4.0.1.tgz#ff0519f7ffaff114f76d1bd7c3996eef0f6e20b4"
integrity sha512-v/KBnfyaOMPmZc/dmc6ozOdWqekGp7bBGq4jLAecEfPGmfKiWS4sA8sC0LqiV9w5qmXAtXVn4M3p1jSyhY85SQ==
eslint-scope@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"
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"
resolved "https://registry.yarnpkg.com/neo4j-driver/-/neo4j-driver-1.7.5.tgz#c3fe3677f69c12f26944563d45e7e7d818a685e4"
integrity sha512-xCD2F5+tp/SD9r5avX5bSoY8u8RH2o793xJ9Ikjz1s5qQy7cFxFbbj2c52uz3BVGhRAx/NmB57VjOquYmmxGtw==
@ -6184,14 +6184,14 @@ neo4j-graphql-js@^2.7.1:
lodash "^4.17.15"
neo4j-driver "^1.7.3"
neode@^0.3.1:
version "0.3.1"
resolved "https://registry.yarnpkg.com/neode/-/neode-0.3.1.tgz#d40147bf20d6951b69c9d392fbdd322aeca07816"
integrity sha512-SdaJmdjQ3PWOH6W1H8Xgd2CLyJs+BPPXPt0jOVNs7naeQH8nWPP6ixDqI6NWDCxwecTdNl//fpAicB9I6hCwEw==
neode@^0.3.2:
version "0.3.2"
resolved "https://registry.yarnpkg.com/neode/-/neode-0.3.2.tgz#ced277e1daba26a77c48f5857c30af054f11c7df"
integrity sha512-Bm4GBXdXunv8cqUUkJtksIGHDnYdBJf4UHwzFgXbJiDKBAdqfjhzwAPAhf1PrvlFmR4vJva2Bh/XvIghYOiKrA==
dependencies:
"@hapi/joi" "^15.1.0"
dotenv "^4.0.0"
neo4j-driver "^1.6.3"
neo4j-driver "^1.7.5"
uuid "^3.3.2"
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"
integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
uuid@^3.1.0, uuid@^3.3.2, uuid@~3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"
integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==
uuid@^3.1.0, uuid@^3.3.2, uuid@~3.3.3:
version "3.3.3"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.3.tgz#4568f0216e78760ee1dbf3a4d2cf53e224112866"
integrity sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==
v8-compile-cache@^2.0.3:
version "2.0.3"

View File

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

View File

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

View File

@ -10,7 +10,7 @@
</ds-card>
</div>
<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">
<hc-user :user="author" :date-time="comment.createdAt" />
</ds-space>

View File

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

View File

@ -2,7 +2,13 @@
<ds-form v-show="!editPending" v-model="form" @submit="handleSubmit">
<template slot-scope="{ errors }">
<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-flex :gutter="{ base: 'small', md: 'small', sm: 'x-large', xs: 'x-large' }">
<ds-flex-item :width="{ base: '0%', md: '50%', sm: '0%', xs: '0%' }" />

View File

@ -16,6 +16,22 @@ describe('Editor.vue', () => {
let mocks
let getters
const Wrapper = () => {
const store = new Vuex.Store({
getters,
})
return (wrapper = mount(Editor, {
mocks,
propsData,
localVue,
sync: false,
stubs: {
transition: false,
},
store,
}))
}
beforeEach(() => {
propsData = {}
mocks = {
@ -26,25 +42,10 @@ describe('Editor.vue', () => {
return 'some cool placeholder'
},
}
wrapper = Wrapper()
})
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', () => {
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,
},
props: {
users: { type: Array, default: () => [] },
hashtags: { type: Array, default: () => [] },
users: { type: Array, default: () => null }, // If 'null', than the Mention extention is not assigned.
hashtags: { type: Array, default: () => null }, // If 'null', than the Hashtag extention is not assigned.
value: { type: String, default: '' },
doc: { type: Object, default: () => {} },
},
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 {
lastValueHash: null,
editor: new Editor({
@ -215,154 +376,7 @@ export default {
...defaultExtensions(this),
new EventHandler(),
new History(),
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)
},
}),
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()),
)
},
}),
...optionalExtensions,
],
onUpdate: e => {
clearTimeout(throttleInputEvent)

View File

@ -1,5 +1,5 @@
import { config, mount, createLocalVue, RouterLinkStub } from '@vue/test-utils'
import Notification from '.'
import Notification from './Notification'
import Styleguide from '@human-connection/styleguide'
import Filters from '~/plugins/vue-filters'
@ -38,6 +38,9 @@ describe('Notification', () => {
propsData.notification = {
post: {
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")
})
it('renders the contentExcerpt', () => {
expect(Wrapper().text()).toContain('@jenny-rostock is the best')
})
it('has no class "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 NotificationList from '.'
import Notification from '../Notification'
import NotificationList from './NotificationList'
import Notification from '../Notification/Notification'
import Vuex from 'vuex'
import Filters from '~/plugins/vue-filters'
@ -45,6 +45,7 @@ describe('NotificationList.vue', () => {
post: {
id: 'post-1',
title: 'some post title',
slug: 'some-post-title',
contentExcerpt: 'this is a post content',
author: {
id: 'john-1',
@ -59,6 +60,7 @@ describe('NotificationList.vue', () => {
post: {
id: 'post-2',
title: 'another post title',
slug: 'another-post-title',
contentExcerpt: 'this is yet another post content',
author: {
id: 'john-1',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -57,11 +57,26 @@ module.exports = {
title: 'Human Connection',
titleTemplate: '%s - Human Connection',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ hid: 'description', name: 'description', content: pkg.description },
{
charset: 'utf-8',
},
{
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'],
linkActiveClass: 'router-link-active',
linkExactActiveClass: 'router-link-exact-active',
scrollBehavior: () => {
return { x: 0, y: 0 }
scrollBehavior: (to, _from, savedPosition) => {
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
*/
modules: [
['@nuxtjs/dotenv', { only: envWhitelist }],
['nuxt-env', { keys: envWhitelist }],
[
'@nuxtjs/dotenv',
{
only: envWhitelist,
},
],
[
'nuxt-env',
{
keys: envWhitelist,
},
],
'cookie-universal-nuxt',
'@nuxtjs/apollo',
'@nuxtjs/axios',
@ -155,7 +251,9 @@ module.exports = {
'/api': {
// make this configurable (nuxt-dotenv)
target: process.env.GRAPHQL_URI || 'http://localhost:4000',
pathRewrite: { '^/api': '' },
pathRewrite: {
'^/api': '',
},
toProxy: true, // cloudflare needs that
headers: {
Accept: 'application/json',

View File

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

View File

@ -83,6 +83,15 @@ export default ({ app = {} }) => {
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 => {
if (!url) return url
return url.startsWith('/') ? url.replace('/', '/api/') : url

View File

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

View File

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

View File

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