diff --git a/backend/src/middleware/handleHtmlContent/handleContentData.js b/backend/src/middleware/handleHtmlContent/handleContentData.js new file mode 100644 index 000000000..6519ddae7 --- /dev/null +++ b/backend/src/middleware/handleHtmlContent/handleContentData.js @@ -0,0 +1,69 @@ +import extractMentionedUsers from './notifications/extractMentionedUsers' +import extractHashtags from './hashtags/extractHashtags' + +const notify = async (postId, idsOfMentionedUsers, context) => { + const session = context.driver.session() + const createdAt = new Date().toISOString() + const cypher = ` + match(u:User) where u.id in $idsOfMentionedUsers + match(p:Post) where p.id = $postId + create(n:Notification{id: apoc.create.uuid(), read: false, createdAt: $createdAt}) + merge (n)-[:NOTIFIED]->(u) + merge (p)-[:NOTIFIED]->(n) + ` + await session.run(cypher, { + idsOfMentionedUsers, + createdAt, + postId, + }) + session.close() +} + +const updateHashtagsOfPost = async (postId, hashtags, context) => { + const session = context.driver.session() + // We need two Cypher statements, because the 'MATCH' in the 'cypherDeletePreviousRelations' statement + // functions as an 'if'. In case there is no previous relation, the rest of the commands are omitted + // and no new Hashtags and relations will be created. + const cypherDeletePreviousRelations = ` + MATCH (p:Post { id: $postId })-[previousRelations:TAGGED]->(t:Tag) + DELETE previousRelations + RETURN p, t + ` + const cypherCreateNewTagsAndRelations = ` + MATCH (p:Post { id: $postId}) + UNWIND $hashtags AS tagName + MERGE (t:Tag { id: tagName, name: tagName, disabled: false, deleted: false }) + MERGE (p)-[:TAGGED]->(t) + RETURN p, t + ` + await session.run(cypherDeletePreviousRelations, { + postId, + }) + await session.run(cypherCreateNewTagsAndRelations, { + postId, + hashtags, + }) + session.close() +} + +const handleContentData = async (resolve, root, args, context, resolveInfo) => { + // extract user ids before xss-middleware removes classes via the following "resolve" call + 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 updateHashtagsOfPost(post.id, hashtags, context) + + return post +} + +export default { + Mutation: { + CreatePost: handleContentData, + UpdatePost: handleContentData, + }, +} diff --git a/backend/src/middleware/handleHtmlContent/handleContentData.spec.js b/backend/src/middleware/handleHtmlContent/handleContentData.spec.js new file mode 100644 index 000000000..aa281e6d7 --- /dev/null +++ b/backend/src/middleware/handleHtmlContent/handleContentData.spec.js @@ -0,0 +1,286 @@ +import { GraphQLClient } from 'graphql-request' +import gql from 'graphql-tag' +import { host, login } from '../../jest/helpers' +import Factory from '../../seed/factories' + +const factory = Factory() +let client + +beforeEach(async () => { + await factory.create('User', { + id: 'you', + name: 'Al Capone', + slug: 'al-capone', + email: 'test@example.org', + password: '1234', + }) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('currentUser { notifications }', () => { + const query = gql` + query($read: Boolean) { + currentUser { + notifications(read: $read, orderBy: createdAt_desc) { + read + post { + content + } + } + } + } + ` + + describe('authenticated', () => { + let headers + beforeEach(async () => { + headers = await login({ + email: 'test@example.org', + password: '1234', + }) + client = new GraphQLClient(host, { + headers, + }) + }) + + describe('given another user', () => { + let authorClient + let authorParams + let authorHeaders + + beforeEach(async () => { + authorParams = { + email: 'author@example.org', + password: '1234', + id: 'author', + } + await factory.create('User', authorParams) + authorHeaders = await login(authorParams) + }) + + describe('who mentions me in a post', () => { + let post + const title = 'Mentioning Al Capone' + const content = + 'Hey @al-capone how do you do?' + + beforeEach(async () => { + const createPostMutation = gql` + mutation($title: String!, $content: String!) { + CreatePost(title: $title, content: $content) { + id + title + content + } + } + ` + authorClient = new GraphQLClient(host, { + headers: authorHeaders, + }) + const { CreatePost } = await authorClient.request(createPostMutation, { + title, + content, + }) + post = CreatePost + }) + + it('sends you a notification', async () => { + const expectedContent = + 'Hey @al-capone how do you do?' + const expected = { + currentUser: { + notifications: [ + { + read: false, + post: { + content: expectedContent, + }, + }, + ], + }, + } + await expect( + client.request(query, { + read: false, + }), + ).resolves.toEqual(expected) + }) + + describe('who mentions me again', () => { + beforeEach(async () => { + const updatedContent = `${post.content} One more mention to @al-capone` + // The response `post.content` contains a link but the XSSmiddleware + // should have the `mention` CSS class removed. I discovered this + // during development and thought: A feature not a bug! This way we + // can encode a re-mentioning of users when you edit your post or + // comment. + const updatePostMutation = gql` + mutation($id: ID!, $title: String!, $content: String!) { + UpdatePost(id: $id, content: $content, title: $title) { + title + content + } + } + ` + authorClient = new GraphQLClient(host, { + headers: authorHeaders, + }) + await authorClient.request(updatePostMutation, { + id: post.id, + title: post.title, + content: updatedContent, + }) + }) + + it('creates exactly one more notification', async () => { + const expectedContent = + 'Hey @al-capone how do you do? One more mention to @al-capone' + const expected = { + currentUser: { + notifications: [ + { + read: false, + post: { + content: expectedContent, + }, + }, + { + read: false, + post: { + content: expectedContent, + }, + }, + ], + }, + } + await expect( + client.request(query, { + read: false, + }), + ).resolves.toEqual(expected) + }) + }) + }) + }) + }) +}) + +describe('Hashtags', () => { + const postId = 'p135' + const postTitle = 'Two Hashtags' + const postContent = + '

Hey Dude, #Democracy should work equal for everybody!? That seems to be the only way to have equal #Liberty for everyone.

' + const postWithHastagsQuery = gql` + query($id: ID) { + Post(id: $id) { + tags { + id + name + } + } + } + ` + const postWithHastagsVariables = { + id: postId, + } + const createPostMutation = gql` + mutation($postId: ID, $postTitle: String!, $postContent: String!) { + CreatePost(id: $postId, title: $postTitle, content: $postContent) { + id + title + content + } + } + ` + + describe('authenticated', () => { + let headers + beforeEach(async () => { + headers = await login({ + email: 'test@example.org', + password: '1234', + }) + client = new GraphQLClient(host, { + headers, + }) + }) + + describe('create a Post with Hashtags', () => { + beforeEach(async () => { + await client.request(createPostMutation, { + postId, + postTitle, + postContent, + }) + }) + + it('both Hashtags are created with the "id" set to thier "name"', async () => { + const expected = [ + { + id: 'Democracy', + name: 'Democracy', + }, + { + id: 'Liberty', + name: 'Liberty', + }, + ] + await expect( + client.request(postWithHastagsQuery, postWithHastagsVariables), + ).resolves.toEqual({ + Post: [ + { + tags: expect.arrayContaining(expected), + }, + ], + }) + }) + + describe('afterwards update the Post by removing a Hashtag, leaving a Hashtag and add a Hashtag', () => { + // The already existing Hashtag has no class at this point. + const updatedPostContent = + '

Hey Dude, #Elections should work equal for everybody!? That seems to be the only way to have equal #Liberty for everyone.

' + const updatePostMutation = gql` + mutation($postId: ID!, $postTitle: String!, $updatedPostContent: String!) { + UpdatePost(id: $postId, title: $postTitle, content: $updatedPostContent) { + id + title + content + } + } + ` + + it('only one previous Hashtag and the new Hashtag exists', async () => { + await client.request(updatePostMutation, { + postId, + postTitle, + updatedPostContent, + }) + + const expected = [ + { + id: 'Elections', + name: 'Elections', + }, + { + id: 'Liberty', + name: 'Liberty', + }, + ] + await expect( + client.request(postWithHastagsQuery, postWithHastagsVariables), + ).resolves.toEqual({ + Post: [ + { + tags: expect.arrayContaining(expected), + }, + ], + }) + }) + }) + }) + }) +}) diff --git a/backend/src/middleware/handleHtmlContent/hashtags/extractHashtags.js b/backend/src/middleware/handleHtmlContent/hashtags/extractHashtags.js new file mode 100644 index 000000000..fd6613065 --- /dev/null +++ b/backend/src/middleware/handleHtmlContent/hashtags/extractHashtags.js @@ -0,0 +1,28 @@ +import cheerio from 'cheerio' +// formats of a Hashtag: +// https://en.wikipedia.org/w/index.php?title=Hashtag&oldid=905141980#Style +// here: +// 0. Search for whole string. +// 1. Hashtag has only 'a-z', 'A-Z', and '0-9'. +// 2. If it starts with a digit '0-9' than 'a-z', or 'A-Z' has to follow. +const ID_REGEX = /^\/search\/hashtag\/(([a-zA-Z]+[a-zA-Z0-9]*)|([0-9]+[a-zA-Z]+[a-zA-Z0-9]*))$/g + +export default function(content) { + if (!content) return [] + const $ = cheerio.load(content) + // We can not search for class '.hashtag', because the classes are removed at the 'xss' middleware. + // But we have to know, which Hashtags are removed from the content es well, so we search for the 'a' html-tag. + const urls = $('a') + .map((_, el) => { + return $(el).attr('href') + }) + .get() + const hashtags = [] + urls.forEach(url => { + let match + while ((match = ID_REGEX.exec(url)) != null) { + hashtags.push(match[1]) + } + }) + return hashtags +} diff --git a/backend/src/middleware/handleHtmlContent/hashtags/extractHashtags.spec.js b/backend/src/middleware/handleHtmlContent/hashtags/extractHashtags.spec.js new file mode 100644 index 000000000..eb581d8f5 --- /dev/null +++ b/backend/src/middleware/handleHtmlContent/hashtags/extractHashtags.spec.js @@ -0,0 +1,57 @@ +import extractHashtags from './extractHashtags' + +describe('extractHashtags', () => { + describe('content undefined', () => { + it('returns empty array', () => { + expect(extractHashtags()).toEqual([]) + }) + }) + + describe('searches through links', () => { + it('finds links with and without ".hashtag" class and extracts Hashtag names', () => { + const content = + '

#Elections#Democracy

' + expect(extractHashtags(content)).toEqual(['Elections', 'Democracy']) + }) + + it('ignores mentions', () => { + const content = + '

Something inspirational about @bob-der-baumeister and @jenny-rostock.

' + expect(extractHashtags(content)).toEqual([]) + }) + + describe('handles links', () => { + it('ignores links with domains', () => { + const content = + '

#Elections#Democracy

' + expect(extractHashtags(content)).toEqual(['Democracy']) + }) + + it('ignores Hashtag links with not allowed character combinations', () => { + const content = + '

Something inspirational about #AbcDefXyz0123456789!*(),2, #0123456789, #0123456789a and #AbcDefXyz0123456789.

' + expect(extractHashtags(content)).toEqual(['0123456789a', 'AbcDefXyz0123456789']) + }) + }) + + describe('does not crash if', () => { + it('`href` contains no Hashtag name', () => { + const content = + '

Something inspirational about #Democracy and #liberty.

' + expect(extractHashtags(content)).toEqual([]) + }) + + it('`href` contains Hashtag as page anchor', () => { + const content = + '

Something inspirational about #anchor.

' + expect(extractHashtags(content)).toEqual([]) + }) + + it('`href` is empty or invalid', () => { + const content = + '

Something inspirational about @bob-der-baumeister and @jenny-rostock.

' + expect(extractHashtags(content)).toEqual([]) + }) + }) + }) +}) diff --git a/backend/src/middleware/notifications/extractIds/index.js b/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.js similarity index 100% rename from backend/src/middleware/notifications/extractIds/index.js rename to backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.js diff --git a/backend/src/middleware/notifications/extractIds/spec.js b/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.spec.js similarity index 79% rename from backend/src/middleware/notifications/extractIds/spec.js rename to backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.spec.js index 341c39cec..f39fbc859 100644 --- a/backend/src/middleware/notifications/extractIds/spec.js +++ b/backend/src/middleware/handleHtmlContent/notifications/extractMentionedUsers.spec.js @@ -1,9 +1,9 @@ -import extractIds from '.' +import extractMentionedUsers from './extractMentionedUsers' -describe('extractIds', () => { +describe('extractMentionedUsers', () => { describe('content undefined', () => { it('returns empty array', () => { - expect(extractIds()).toEqual([]) + expect(extractMentionedUsers()).toEqual([]) }) }) @@ -11,33 +11,33 @@ describe('extractIds', () => { it('ignores links without .mention class', () => { const content = '

Something inspirational about @bob-der-baumeister and @jenny-rostock.

' - expect(extractIds(content)).toEqual([]) + expect(extractMentionedUsers(content)).toEqual([]) }) describe('given a link with .mention class', () => { it('extracts ids', () => { const content = '

Something inspirational about @bob-der-baumeister and @jenny-rostock.

' - expect(extractIds(content)).toEqual(['u2', 'u3']) + expect(extractMentionedUsers(content)).toEqual(['u2', 'u3']) }) describe('handles links', () => { it('with slug and id', () => { const content = '

Something inspirational about @bob-der-baumeister and @jenny-rostock.

' - expect(extractIds(content)).toEqual(['u2', 'u3']) + expect(extractMentionedUsers(content)).toEqual(['u2', 'u3']) }) it('with domains', () => { const content = '

Something inspirational about @bob-der-baumeister and @jenny-rostock.

' - expect(extractIds(content)).toEqual(['u2', 'u3']) + expect(extractMentionedUsers(content)).toEqual(['u2', 'u3']) }) it('special characters', () => { const content = '

Something inspirational about @bob-der-baumeister and @jenny-rostock.

' - expect(extractIds(content)).toEqual(['u!*(),2', 'u.~-3']) + expect(extractMentionedUsers(content)).toEqual(['u!*(),2', 'u.~-3']) }) }) @@ -45,13 +45,13 @@ describe('extractIds', () => { it('`href` contains no user id', () => { const content = '

Something inspirational about @bob-der-baumeister and @jenny-rostock.

' - expect(extractIds(content)).toEqual([]) + expect(extractMentionedUsers(content)).toEqual([]) }) it('`href` is empty or invalid', () => { const content = '

Something inspirational about @bob-der-baumeister and @jenny-rostock.

' - expect(extractIds(content)).toEqual([]) + expect(extractMentionedUsers(content)).toEqual([]) }) }) }) diff --git a/backend/src/middleware/index.js b/backend/src/middleware/index.js index 14f85f91a..fd631256d 100644 --- a/backend/src/middleware/index.js +++ b/backend/src/middleware/index.js @@ -10,7 +10,7 @@ import user from './userMiddleware' import includedFields from './includedFieldsMiddleware' import orderBy from './orderByMiddleware' import validation from './validation/validationMiddleware' -import notifications from './notifications' +import handleContentData from './handleHtmlContent/handleContentData' import email from './email/emailMiddleware' export default schema => { @@ -21,7 +21,7 @@ export default schema => { validation: validation, sluggify: sluggify, excerpt: excerpt, - notifications: notifications, + handleContentData: handleContentData, xss: xss, softDelete: softDelete, user: user, @@ -38,7 +38,7 @@ export default schema => { 'sluggify', 'excerpt', 'email', - 'notifications', + 'handleContentData', 'xss', 'softDelete', 'user', diff --git a/backend/src/middleware/notifications/index.js b/backend/src/middleware/notifications/index.js deleted file mode 100644 index ca460a512..000000000 --- a/backend/src/middleware/notifications/index.js +++ /dev/null @@ -1,30 +0,0 @@ -import extractIds from './extractIds' - -const notify = async (resolve, root, args, context, resolveInfo) => { - // extract user ids before xss-middleware removes link classes - const ids = extractIds(args.content) - - const post = await resolve(root, args, context, resolveInfo) - - const session = context.driver.session() - const { id: postId } = post - const createdAt = new Date().toISOString() - const cypher = ` - match(u:User) where u.id in $ids - match(p:Post) where p.id = $postId - create(n:Notification{id: apoc.create.uuid(), read: false, createdAt: $createdAt}) - merge (n)-[:NOTIFIED]->(u) - merge (p)-[:NOTIFIED]->(n) - ` - await session.run(cypher, { ids, createdAt, postId }) - session.close() - - return post -} - -export default { - Mutation: { - CreatePost: notify, - UpdatePost: notify, - }, -} diff --git a/backend/src/middleware/notifications/spec.js b/backend/src/middleware/notifications/spec.js deleted file mode 100644 index d214a5571..000000000 --- a/backend/src/middleware/notifications/spec.js +++ /dev/null @@ -1,130 +0,0 @@ -import { GraphQLClient } from 'graphql-request' -import { host, login } from '../../jest/helpers' -import Factory from '../../seed/factories' - -const factory = Factory() -let client - -beforeEach(async () => { - await factory.create('User', { - id: 'you', - name: 'Al Capone', - slug: 'al-capone', - email: 'test@example.org', - password: '1234', - }) -}) - -afterEach(async () => { - await factory.cleanDatabase() -}) - -describe('currentUser { notifications }', () => { - const query = `query($read: Boolean) { - currentUser { - notifications(read: $read, orderBy: createdAt_desc) { - read - post { - content - } - } - } - }` - - describe('authenticated', () => { - let headers - beforeEach(async () => { - headers = await login({ email: 'test@example.org', password: '1234' }) - client = new GraphQLClient(host, { headers }) - }) - - describe('given another user', () => { - let authorClient - let authorParams - let authorHeaders - - beforeEach(async () => { - authorParams = { - email: 'author@example.org', - password: '1234', - id: 'author', - } - await factory.create('User', authorParams) - authorHeaders = await login(authorParams) - }) - - describe('who mentions me in a post', () => { - let post - const title = 'Mentioning Al Capone' - const content = - 'Hey @al-capone how do you do?' - - beforeEach(async () => { - const createPostMutation = ` - mutation($title: String!, $content: String!) { - CreatePost(title: $title, content: $content) { - id - title - content - } - } - ` - authorClient = new GraphQLClient(host, { headers: authorHeaders }) - const { CreatePost } = await authorClient.request(createPostMutation, { title, content }) - post = CreatePost - }) - - it('sends you a notification', async () => { - const expectedContent = - 'Hey @al-capone how do you do?' - const expected = { - currentUser: { - notifications: [{ read: false, post: { content: expectedContent } }], - }, - } - await expect(client.request(query, { read: false })).resolves.toEqual(expected) - }) - - describe('who mentions me again', () => { - beforeEach(async () => { - const updatedContent = `${post.content} One more mention to @al-capone` - const updatedTitle = 'this post has been updated' - // The response `post.content` contains a link but the XSSmiddleware - // should have the `mention` CSS class removed. I discovered this - // during development and thought: A feature not a bug! This way we - // can encode a re-mentioning of users when you edit your post or - // comment. - const updatePostMutation = ` - mutation($id: ID!, $title: String!, $content: String!) { - UpdatePost(id: $id, title: $title, content: $content) { - title - content - } - } - ` - authorClient = new GraphQLClient(host, { headers: authorHeaders }) - await authorClient.request(updatePostMutation, { - id: post.id, - content: updatedContent, - title: updatedTitle, - }) - }) - - it('creates exactly one more notification', async () => { - const expectedContent = - 'Hey @al-capone how do you do? One more mention to @al-capone' - const expected = { - currentUser: { - notifications: [ - { read: false, post: { content: expectedContent } }, - { read: false, post: { content: expectedContent } }, - ], - }, - } - await expect(client.request(query, { read: false })).resolves.toEqual(expected) - }) - }) - }) - }) - }) -}) diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 101713f91..a6b6ef0da 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -137,7 +137,7 @@ const permissions = shield( '*': deny, findPosts: allow, Category: allow, - Tag: isAdmin, + Tag: allow, Report: isModerator, Notification: isAdmin, statistics: allow, diff --git a/backend/src/seed/seed-db.js b/backend/src/seed/seed-db.js index e31d09a68..18eefb76f 100644 --- a/backend/src/seed/seed-db.js +++ b/backend/src/seed/seed-db.js @@ -69,47 +69,144 @@ import Factory from './factories' role: 'user', email: 'user@example.org', }), - f.create('User', { id: 'u4', name: 'Tick', role: 'user', email: 'tick@example.org' }), - f.create('User', { id: 'u5', name: 'Trick', role: 'user', email: 'trick@example.org' }), - f.create('User', { id: 'u6', name: 'Track', role: 'user', email: 'track@example.org' }), - f.create('User', { id: 'u7', name: 'Dagobert', role: 'user', email: 'dagobert@example.org' }), + f.create('User', { + id: 'u4', + name: 'Tick', + role: 'user', + email: 'tick@example.org', + }), + f.create('User', { + id: 'u5', + name: 'Trick', + role: 'user', + email: 'trick@example.org', + }), + f.create('User', { + id: 'u6', + name: 'Track', + role: 'user', + email: 'track@example.org', + }), + f.create('User', { + id: 'u7', + name: 'Dagobert', + role: 'user', + email: 'dagobert@example.org', + }), ]) const [asAdmin, asModerator, asUser, asTick, asTrick, asTrack] = await Promise.all([ - Factory().authenticateAs({ email: 'admin@example.org', password: '1234' }), - Factory().authenticateAs({ email: 'moderator@example.org', password: '1234' }), - Factory().authenticateAs({ email: 'user@example.org', password: '1234' }), - Factory().authenticateAs({ email: 'tick@example.org', password: '1234' }), - Factory().authenticateAs({ email: 'trick@example.org', password: '1234' }), - Factory().authenticateAs({ email: 'track@example.org', password: '1234' }), + Factory().authenticateAs({ + email: 'admin@example.org', + password: '1234', + }), + Factory().authenticateAs({ + email: 'moderator@example.org', + password: '1234', + }), + Factory().authenticateAs({ + email: 'user@example.org', + password: '1234', + }), + Factory().authenticateAs({ + email: 'tick@example.org', + password: '1234', + }), + Factory().authenticateAs({ + email: 'trick@example.org', + password: '1234', + }), + Factory().authenticateAs({ + email: 'track@example.org', + password: '1234', + }), ]) await Promise.all([ - f.relate('User', 'Badges', { from: 'b6', to: 'u1' }), - f.relate('User', 'Badges', { from: 'b5', to: 'u2' }), - f.relate('User', 'Badges', { from: 'b4', to: 'u3' }), - f.relate('User', 'Badges', { from: 'b3', to: 'u4' }), - f.relate('User', 'Badges', { from: 'b2', to: 'u5' }), - f.relate('User', 'Badges', { from: 'b1', to: 'u6' }), - f.relate('User', 'Friends', { from: 'u1', to: 'u2' }), - f.relate('User', 'Friends', { from: 'u1', to: 'u3' }), - f.relate('User', 'Friends', { from: 'u2', to: 'u3' }), - f.relate('User', 'Blacklisted', { from: 'u7', to: 'u4' }), - f.relate('User', 'Blacklisted', { from: 'u7', to: 'u5' }), - f.relate('User', 'Blacklisted', { from: 'u7', to: 'u6' }), + f.relate('User', 'Badges', { + from: 'b6', + to: 'u1', + }), + f.relate('User', 'Badges', { + from: 'b5', + to: 'u2', + }), + f.relate('User', 'Badges', { + from: 'b4', + to: 'u3', + }), + f.relate('User', 'Badges', { + from: 'b3', + to: 'u4', + }), + f.relate('User', 'Badges', { + from: 'b2', + to: 'u5', + }), + f.relate('User', 'Badges', { + from: 'b1', + to: 'u6', + }), + f.relate('User', 'Friends', { + from: 'u1', + to: 'u2', + }), + f.relate('User', 'Friends', { + from: 'u1', + to: 'u3', + }), + f.relate('User', 'Friends', { + from: 'u2', + to: 'u3', + }), + f.relate('User', 'Blacklisted', { + from: 'u7', + to: 'u4', + }), + f.relate('User', 'Blacklisted', { + from: 'u7', + to: 'u5', + }), + f.relate('User', 'Blacklisted', { + from: 'u7', + to: 'u6', + }), ]) await Promise.all([ - asAdmin.follow({ id: 'u3', type: 'User' }), - asModerator.follow({ id: 'u4', type: 'User' }), - asUser.follow({ id: 'u4', type: 'User' }), - asTick.follow({ id: 'u6', type: 'User' }), - asTrick.follow({ id: 'u4', type: 'User' }), - asTrack.follow({ id: 'u3', type: 'User' }), + asAdmin.follow({ + id: 'u3', + type: 'User', + }), + asModerator.follow({ + id: 'u4', + type: 'User', + }), + asUser.follow({ + id: 'u4', + type: 'User', + }), + asTick.follow({ + id: 'u6', + type: 'User', + }), + asTrick.follow({ + id: 'u4', + type: 'User', + }), + asTrack.follow({ + id: 'u3', + type: 'User', + }), ]) await Promise.all([ - f.create('Category', { id: 'cat1', name: 'Just For Fun', slug: 'justforfun', icon: 'smile' }), + f.create('Category', { + id: 'cat1', + name: 'Just For Fun', + slug: 'justforfun', + icon: 'smile', + }), f.create('Category', { id: 'cat2', name: 'Happyness & Values', @@ -203,10 +300,22 @@ import Factory from './factories' ]) await Promise.all([ - f.create('Tag', { id: 't1', name: 'Umwelt' }), - f.create('Tag', { id: 't2', name: 'Naturschutz' }), - f.create('Tag', { id: 't3', name: 'Demokratie' }), - f.create('Tag', { id: 't4', name: 'Freiheit' }), + f.create('Tag', { + id: 'Umwelt', + name: 'Umwelt', + }), + f.create('Tag', { + id: 'Naturschutz', + name: 'Naturschutz', + }), + f.create('Tag', { + id: 'Demokratie', + name: 'Demokratie', + }), + f.create('Tag', { + id: 'Freiheit', + name: 'Freiheit', + }), ]) const mention1 = 'Hey @jenny-rostock, what\'s up?' @@ -214,108 +323,347 @@ import Factory from './factories' 'Hey @jenny-rostock, here is another notification for you!' await Promise.all([ - asAdmin.create('Post', { id: 'p0', image: faker.image.unsplash.food() }), - asModerator.create('Post', { id: 'p1', image: faker.image.unsplash.technology() }), - asUser.create('Post', { id: 'p2' }), - asTick.create('Post', { id: 'p3' }), - asTrick.create('Post', { id: 'p4' }), - asTrack.create('Post', { id: 'p5' }), - asAdmin.create('Post', { id: 'p6', image: faker.image.unsplash.buildings() }), - asModerator.create('Post', { id: 'p7', content: `${mention1} ${faker.lorem.paragraph()}` }), - asUser.create('Post', { id: 'p8', image: faker.image.unsplash.nature() }), - asTick.create('Post', { id: 'p9' }), - asTrick.create('Post', { id: 'p10' }), - asTrack.create('Post', { id: 'p11', image: faker.image.unsplash.people() }), - asAdmin.create('Post', { id: 'p12', content: `${mention2} ${faker.lorem.paragraph()}` }), - asModerator.create('Post', { id: 'p13' }), - asUser.create('Post', { id: 'p14', image: faker.image.unsplash.objects() }), - asTick.create('Post', { id: 'p15' }), + asAdmin.create('Post', { + id: 'p0', + image: faker.image.unsplash.food(), + }), + asModerator.create('Post', { + id: 'p1', + image: faker.image.unsplash.technology(), + }), + asUser.create('Post', { + id: 'p2', + }), + asTick.create('Post', { + id: 'p3', + }), + asTrick.create('Post', { + id: 'p4', + }), + asTrack.create('Post', { + id: 'p5', + }), + asAdmin.create('Post', { + id: 'p6', + image: faker.image.unsplash.buildings(), + }), + asModerator.create('Post', { + id: 'p7', + content: `${mention1} ${faker.lorem.paragraph()}`, + }), + asUser.create('Post', { + id: 'p8', + image: faker.image.unsplash.nature(), + }), + asTick.create('Post', { + id: 'p9', + }), + asTrick.create('Post', { + id: 'p10', + }), + asTrack.create('Post', { + id: 'p11', + image: faker.image.unsplash.people(), + }), + asAdmin.create('Post', { + id: 'p12', + content: `${mention2} ${faker.lorem.paragraph()}`, + }), + asModerator.create('Post', { + id: 'p13', + }), + asUser.create('Post', { + id: 'p14', + image: faker.image.unsplash.objects(), + }), + asTick.create('Post', { + id: 'p15', + }), ]) await Promise.all([ - f.relate('Post', 'Categories', { from: 'p0', to: 'cat16' }), - f.relate('Post', 'Categories', { from: 'p1', to: 'cat1' }), - f.relate('Post', 'Categories', { from: 'p2', to: 'cat2' }), - f.relate('Post', 'Categories', { from: 'p3', to: 'cat3' }), - f.relate('Post', 'Categories', { from: 'p4', to: 'cat4' }), - f.relate('Post', 'Categories', { from: 'p5', to: 'cat5' }), - f.relate('Post', 'Categories', { from: 'p6', to: 'cat6' }), - f.relate('Post', 'Categories', { from: 'p7', to: 'cat7' }), - f.relate('Post', 'Categories', { from: 'p8', to: 'cat8' }), - f.relate('Post', 'Categories', { from: 'p9', to: 'cat9' }), - f.relate('Post', 'Categories', { from: 'p10', to: 'cat10' }), - f.relate('Post', 'Categories', { from: 'p11', to: 'cat11' }), - f.relate('Post', 'Categories', { from: 'p12', to: 'cat12' }), - f.relate('Post', 'Categories', { from: 'p13', to: 'cat13' }), - f.relate('Post', 'Categories', { from: 'p14', to: 'cat14' }), - f.relate('Post', 'Categories', { from: 'p15', to: 'cat15' }), + f.relate('Post', 'Categories', { + from: 'p0', + to: 'cat16', + }), + f.relate('Post', 'Categories', { + from: 'p1', + to: 'cat1', + }), + f.relate('Post', 'Categories', { + from: 'p2', + to: 'cat2', + }), + f.relate('Post', 'Categories', { + from: 'p3', + to: 'cat3', + }), + f.relate('Post', 'Categories', { + from: 'p4', + to: 'cat4', + }), + f.relate('Post', 'Categories', { + from: 'p5', + to: 'cat5', + }), + f.relate('Post', 'Categories', { + from: 'p6', + to: 'cat6', + }), + f.relate('Post', 'Categories', { + from: 'p7', + to: 'cat7', + }), + f.relate('Post', 'Categories', { + from: 'p8', + to: 'cat8', + }), + f.relate('Post', 'Categories', { + from: 'p9', + to: 'cat9', + }), + f.relate('Post', 'Categories', { + from: 'p10', + to: 'cat10', + }), + f.relate('Post', 'Categories', { + from: 'p11', + to: 'cat11', + }), + f.relate('Post', 'Categories', { + from: 'p12', + to: 'cat12', + }), + f.relate('Post', 'Categories', { + from: 'p13', + to: 'cat13', + }), + f.relate('Post', 'Categories', { + from: 'p14', + to: 'cat14', + }), + f.relate('Post', 'Categories', { + from: 'p15', + to: 'cat15', + }), - f.relate('Post', 'Tags', { from: 'p0', to: 't4' }), - f.relate('Post', 'Tags', { from: 'p1', to: 't1' }), - f.relate('Post', 'Tags', { from: 'p2', to: 't2' }), - f.relate('Post', 'Tags', { from: 'p3', to: 't3' }), - f.relate('Post', 'Tags', { from: 'p4', to: 't4' }), - f.relate('Post', 'Tags', { from: 'p5', to: 't1' }), - f.relate('Post', 'Tags', { from: 'p6', to: 't2' }), - f.relate('Post', 'Tags', { from: 'p7', to: 't3' }), - f.relate('Post', 'Tags', { from: 'p8', to: 't4' }), - f.relate('Post', 'Tags', { from: 'p9', to: 't1' }), - f.relate('Post', 'Tags', { from: 'p10', to: 't2' }), - f.relate('Post', 'Tags', { from: 'p11', to: 't3' }), - f.relate('Post', 'Tags', { from: 'p12', to: 't4' }), - f.relate('Post', 'Tags', { from: 'p13', to: 't1' }), - f.relate('Post', 'Tags', { from: 'p14', to: 't2' }), - f.relate('Post', 'Tags', { from: 'p15', to: 't3' }), + f.relate('Post', 'Tags', { + from: 'p0', + to: 'Freiheit', + }), + f.relate('Post', 'Tags', { + from: 'p1', + to: 'Umwelt', + }), + f.relate('Post', 'Tags', { + from: 'p2', + to: 'Naturschutz', + }), + f.relate('Post', 'Tags', { + from: 'p3', + to: 'Demokratie', + }), + f.relate('Post', 'Tags', { + from: 'p4', + to: 'Freiheit', + }), + f.relate('Post', 'Tags', { + from: 'p5', + to: 'Umwelt', + }), + f.relate('Post', 'Tags', { + from: 'p6', + to: 'Naturschutz', + }), + f.relate('Post', 'Tags', { + from: 'p7', + to: 'Demokratie', + }), + f.relate('Post', 'Tags', { + from: 'p8', + to: 'Freiheit', + }), + f.relate('Post', 'Tags', { + from: 'p9', + to: 'Umwelt', + }), + f.relate('Post', 'Tags', { + from: 'p10', + to: 'Naturschutz', + }), + f.relate('Post', 'Tags', { + from: 'p11', + to: 'Demokratie', + }), + f.relate('Post', 'Tags', { + from: 'p12', + to: 'Freiheit', + }), + f.relate('Post', 'Tags', { + from: 'p13', + to: 'Umwelt', + }), + f.relate('Post', 'Tags', { + from: 'p14', + to: 'Naturschutz', + }), + f.relate('Post', 'Tags', { + from: 'p15', + to: 'Demokratie', + }), ]) await Promise.all([ - asAdmin.shout({ id: 'p2', type: 'Post' }), - asAdmin.shout({ id: 'p6', type: 'Post' }), - asModerator.shout({ id: 'p0', type: 'Post' }), - asModerator.shout({ id: 'p6', type: 'Post' }), - asUser.shout({ id: 'p6', type: 'Post' }), - asUser.shout({ id: 'p7', type: 'Post' }), - asTick.shout({ id: 'p8', type: 'Post' }), - asTick.shout({ id: 'p9', type: 'Post' }), - asTrack.shout({ id: 'p10', type: 'Post' }), + asAdmin.shout({ + id: 'p2', + type: 'Post', + }), + asAdmin.shout({ + id: 'p6', + type: 'Post', + }), + asModerator.shout({ + id: 'p0', + type: 'Post', + }), + asModerator.shout({ + id: 'p6', + type: 'Post', + }), + asUser.shout({ + id: 'p6', + type: 'Post', + }), + asUser.shout({ + id: 'p7', + type: 'Post', + }), + asTick.shout({ + id: 'p8', + type: 'Post', + }), + asTick.shout({ + id: 'p9', + type: 'Post', + }), + asTrack.shout({ + id: 'p10', + type: 'Post', + }), ]) await Promise.all([ - asAdmin.shout({ id: 'p2', type: 'Post' }), - asAdmin.shout({ id: 'p6', type: 'Post' }), - asModerator.shout({ id: 'p0', type: 'Post' }), - asModerator.shout({ id: 'p6', type: 'Post' }), - asUser.shout({ id: 'p6', type: 'Post' }), - asUser.shout({ id: 'p7', type: 'Post' }), - asTick.shout({ id: 'p8', type: 'Post' }), - asTick.shout({ id: 'p9', type: 'Post' }), - asTrack.shout({ id: 'p10', type: 'Post' }), + asAdmin.shout({ + id: 'p2', + type: 'Post', + }), + asAdmin.shout({ + id: 'p6', + type: 'Post', + }), + asModerator.shout({ + id: 'p0', + type: 'Post', + }), + asModerator.shout({ + id: 'p6', + type: 'Post', + }), + asUser.shout({ + id: 'p6', + type: 'Post', + }), + asUser.shout({ + id: 'p7', + type: 'Post', + }), + asTick.shout({ + id: 'p8', + type: 'Post', + }), + asTick.shout({ + id: 'p9', + type: 'Post', + }), + asTrack.shout({ + id: 'p10', + type: 'Post', + }), ]) await Promise.all([ - asUser.create('Comment', { id: 'c1', postId: 'p1' }), - asTick.create('Comment', { id: 'c2', postId: 'p1' }), - asTrack.create('Comment', { id: 'c3', postId: 'p3' }), - asTrick.create('Comment', { id: 'c4', postId: 'p2' }), - asModerator.create('Comment', { id: 'c5', postId: 'p3' }), - asAdmin.create('Comment', { id: 'c6', postId: 'p4' }), - asUser.create('Comment', { id: 'c7', postId: 'p2' }), - asTick.create('Comment', { id: 'c8', postId: 'p15' }), - asTrick.create('Comment', { id: 'c9', postId: 'p15' }), - asTrack.create('Comment', { id: 'c10', postId: 'p15' }), - asUser.create('Comment', { id: 'c11', postId: 'p15' }), - asUser.create('Comment', { id: 'c12', postId: 'p15' }), + asUser.create('Comment', { + id: 'c1', + postId: 'p1', + }), + asTick.create('Comment', { + id: 'c2', + postId: 'p1', + }), + asTrack.create('Comment', { + id: 'c3', + postId: 'p3', + }), + asTrick.create('Comment', { + id: 'c4', + postId: 'p2', + }), + asModerator.create('Comment', { + id: 'c5', + postId: 'p3', + }), + asAdmin.create('Comment', { + id: 'c6', + postId: 'p4', + }), + asUser.create('Comment', { + id: 'c7', + postId: 'p2', + }), + asTick.create('Comment', { + id: 'c8', + postId: 'p15', + }), + asTrick.create('Comment', { + id: 'c9', + postId: 'p15', + }), + asTrack.create('Comment', { + id: 'c10', + postId: 'p15', + }), + asUser.create('Comment', { + id: 'c11', + postId: 'p15', + }), + asUser.create('Comment', { + id: 'c12', + postId: 'p15', + }), ]) const disableMutation = 'mutation($id: ID!) { disable(id: $id) }' await Promise.all([ - asModerator.mutate(disableMutation, { id: 'p11' }), - asModerator.mutate(disableMutation, { id: 'c5' }), + asModerator.mutate(disableMutation, { + id: 'p11', + }), + asModerator.mutate(disableMutation, { + id: 'c5', + }), ]) await Promise.all([ - asTick.create('Report', { description: "I don't like this comment", id: 'c1' }), - asTrick.create('Report', { description: "I don't like this post", id: 'p1' }), - asTrack.create('Report', { description: "I don't like this user", id: 'u1' }), + asTick.create('Report', { + description: "I don't like this comment", + id: 'c1', + }), + asTrick.create('Report', { + description: "I don't like this post", + id: 'p1', + }), + asTrack.create('Report', { + description: "I don't like this user", + id: 'u1', + }), ]) await Promise.all([ @@ -342,10 +690,22 @@ import Factory from './factories' ]) await Promise.all([ - f.relate('Organization', 'CreatedBy', { from: 'u1', to: 'o1' }), - f.relate('Organization', 'CreatedBy', { from: 'u1', to: 'o2' }), - f.relate('Organization', 'OwnedBy', { from: 'u2', to: 'o2' }), - f.relate('Organization', 'OwnedBy', { from: 'u2', to: 'o3' }), + f.relate('Organization', 'CreatedBy', { + from: 'u1', + to: 'o1', + }), + f.relate('Organization', 'CreatedBy', { + from: 'u1', + to: 'o2', + }), + f.relate('Organization', 'OwnedBy', { + from: 'u2', + to: 'o2', + }), + f.relate('Organization', 'OwnedBy', { + from: 'u2', + to: 'o3', + }), ]) /* eslint-disable-next-line no-console */ console.log('Seeded Data...') diff --git a/webapp/components/ContributionForm/ContributionForm.spec.js b/webapp/components/ContributionForm/ContributionForm.spec.js index 0813d16f0..3d136ff4b 100644 --- a/webapp/components/ContributionForm/ContributionForm.spec.js +++ b/webapp/components/ContributionForm/ContributionForm.spec.js @@ -47,7 +47,9 @@ describe('ContributionForm.vue', () => { }, }, }) - .mockRejectedValue({ message: 'Not Authorised!' }), + .mockRejectedValue({ + message: 'Not Authorised!', + }), }, $toast: { error: jest.fn(), @@ -74,12 +76,26 @@ describe('ContributionForm.vue', () => { getters, }) const Wrapper = () => { - return mount(ContributionForm, { mocks, localVue, store, propsData }) + return mount(ContributionForm, { + mocks, + localVue, + store, + propsData, + }) } beforeEach(() => { wrapper = Wrapper() - wrapper.setData({ form: { languageOptions: [{ label: 'Deutsch', value: 'de' }] } }) + wrapper.setData({ + form: { + languageOptions: [ + { + label: 'Deutsch', + value: 'de', + }, + ], + }, + }) }) describe('CreatePost', () => { diff --git a/webapp/components/ContributionForm/ContributionForm.vue b/webapp/components/ContributionForm/ContributionForm.vue index c6bb2cdc4..593ff2dc6 100644 --- a/webapp/components/ContributionForm/ContributionForm.vue +++ b/webapp/components/ContributionForm/ContributionForm.vue @@ -11,7 +11,12 @@ - + +
{{ $t('actions.cancel') }} import gql from 'graphql-tag' -import HcEditor from '~/components/Editor' +import HcEditor from '~/components/Editor/Editor' import orderBy from 'lodash/orderBy' import locales from '~/locales' import PostMutations from '~/graphql/PostMutations.js' @@ -95,6 +101,7 @@ export default { disabled: false, slug: null, users: [], + hashtags: [], } }, watch: { @@ -193,17 +200,34 @@ export default { apollo: { User: { query() { - return gql(`{ - User(orderBy: slug_asc) { - id - slug + return gql` + { + User(orderBy: slug_asc) { + id + slug + } } - }`) + ` }, result(result) { this.users = result.data.User }, }, + Tag: { + query() { + return gql` + { + Tag(orderBy: name_asc) { + id + name + } + } + ` + }, + result(result) { + this.hashtags = result.data.Tag + }, + }, }, } diff --git a/webapp/components/Editor/spec.js b/webapp/components/Editor/Editor.spec.js similarity index 94% rename from webapp/components/Editor/spec.js rename to webapp/components/Editor/Editor.spec.js index b982d941d..d457609bd 100644 --- a/webapp/components/Editor/spec.js +++ b/webapp/components/Editor/Editor.spec.js @@ -1,5 +1,5 @@ import { mount, createLocalVue } from '@vue/test-utils' -import Editor from './' +import Editor from './Editor' import Vuex from 'vuex' import Styleguide from '@human-connection/styleguide' @@ -36,7 +36,9 @@ describe('Editor.vue', () => { propsData, localVue, sync: false, - stubs: { transition: false }, + stubs: { + transition: false, + }, store, })) } diff --git a/webapp/components/Editor/index.vue b/webapp/components/Editor/Editor.vue similarity index 67% rename from webapp/components/Editor/index.vue rename to webapp/components/Editor/Editor.vue index 84649f436..4413bfa0d 100644 --- a/webapp/components/Editor/index.vue +++ b/webapp/components/Editor/Editor.vue @@ -1,18 +1,51 @@