diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index d972603a6..78f833c23 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -157,6 +157,8 @@ const permissions = shield( User: or(noEmailFilter, isAdmin), isLoggedIn: allow, Badge: allow, + PostsEmotionsCountByEmotion: allow, + PostsEmotionsByCurrentUser: allow, }, Mutation: { '*': deny, @@ -178,7 +180,6 @@ const permissions = shield( // RemoveBadgeRewarded: isAdmin, reward: isAdmin, unreward: isAdmin, - // addFruitToBasket: isAuthenticated follow: isAuthenticated, unfollow: isAuthenticated, shout: isAuthenticated, @@ -192,6 +193,8 @@ const permissions = shield( DeleteUser: isDeletingOwnAccount, requestPasswordReset: allow, resetPassword: allow, + AddPostEmotions: isAuthenticated, + RemovePostEmotions: isAuthenticated, }, User: { email: isMyOwn, diff --git a/backend/src/models/Post.js b/backend/src/models/Post.js new file mode 100644 index 000000000..7dd62287d --- /dev/null +++ b/backend/src/models/Post.js @@ -0,0 +1,34 @@ +import uuid from 'uuid/v4' + +module.exports = { + id: { type: 'string', primary: true, default: uuid }, + activityId: { type: 'string', allow: [null] }, + objectId: { type: 'string', allow: [null] }, + author: { + type: 'relationship', + relationship: 'WROTE', + target: 'User', + direction: 'in', + }, + title: { type: 'string', disallow: [null], min: 3 }, + slug: { type: 'string', allow: [null] }, + content: { type: 'string', disallow: [null], min: 3 }, + contentExcerpt: { type: 'string', allow: [null] }, + image: { type: 'string', allow: [null] }, + deleted: { type: 'boolean', default: false }, + disabled: { type: 'boolean', default: false }, + disabledBy: { + type: 'relationship', + relationship: 'DISABLED', + target: 'User', + direction: 'in', + }, + createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + updatedAt: { + type: 'string', + isoDate: true, + required: true, + default: () => new Date().toISOString(), + }, + language: { type: 'string', allow: [null] }, +} diff --git a/backend/src/models/User.js b/backend/src/models/User.js index bcd9fcf35..2c1575423 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -56,4 +56,19 @@ module.exports = { required: true, default: () => new Date().toISOString(), }, + emoted: { + type: 'relationships', + relationship: 'EMOTED', + target: 'Post', + direction: 'out', + properties: { + emotion: { + type: 'string', + valid: ['happy', 'cry', 'surprised', 'angry', 'funny'], + invalid: [null], + }, + }, + eager: true, + cascade: true, + }, } diff --git a/backend/src/models/index.js b/backend/src/models/index.js index b468dedf2..b8dc451d7 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -6,4 +6,5 @@ export default { InvitationCode: require('./InvitationCode.js'), EmailAddress: require('./EmailAddress.js'), SocialMedia: require('./SocialMedia.js'), + Post: require('./Post.js'), } diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index 0c8dfb7f0..336f816f5 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -44,6 +44,7 @@ export default { delete params.categoryIds params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) params.id = params.id || uuid() + let createPostCypher = `CREATE (post:Post {params}) WITH post MATCH (author:User {id: $userId}) @@ -70,5 +71,80 @@ export default { return post.properties }, + AddPostEmotions: async (object, params, context, resolveInfo) => { + const session = context.driver.session() + const { to, data } = params + const { user } = context + const transactionRes = await session.run( + `MATCH (userFrom:User {id: $user.id}), (postTo:Post {id: $to.id}) + MERGE (userFrom)-[emotedRelation:EMOTED {emotion: $data.emotion}]->(postTo) + RETURN userFrom, postTo, emotedRelation`, + { user, to, data }, + ) + session.close() + const [emoted] = transactionRes.records.map(record => { + return { + from: { ...record.get('userFrom').properties }, + to: { ...record.get('postTo').properties }, + ...record.get('emotedRelation').properties, + } + }) + return emoted + }, + RemovePostEmotions: async (object, params, context, resolveInfo) => { + const session = context.driver.session() + const { to, data } = params + const { id: from } = context.user + const transactionRes = await session.run( + `MATCH (userFrom:User {id: $from})-[emotedRelation:EMOTED {emotion: $data.emotion}]->(postTo:Post {id: $to.id}) + DELETE emotedRelation + RETURN userFrom, postTo`, + { from, to, data }, + ) + session.close() + const [emoted] = transactionRes.records.map(record => { + return { + from: { ...record.get('userFrom').properties }, + to: { ...record.get('postTo').properties }, + emotion: data.emotion, + } + }) + return emoted + }, + }, + Query: { + PostsEmotionsCountByEmotion: async (object, params, context, resolveInfo) => { + const session = context.driver.session() + const { postId, data } = params + const transactionRes = await session.run( + `MATCH (post:Post {id: $postId})<-[emoted:EMOTED {emotion: $data.emotion}]-() + RETURN COUNT(DISTINCT emoted) as emotionsCount + `, + { postId, data }, + ) + session.close() + + const [emotionsCount] = transactionRes.records.map(record => { + return record.get('emotionsCount').low + }) + + return emotionsCount + }, + PostsEmotionsByCurrentUser: async (object, params, context, resolveInfo) => { + const session = context.driver.session() + const { postId } = params + const transactionRes = await session.run( + `MATCH (user:User {id: $userId})-[emoted:EMOTED]->(post:Post {id: $postId}) + RETURN collect(emoted.emotion) as emotion`, + { userId: context.user.id, postId }, + ) + + session.close() + + const [emotions] = transactionRes.records.map(record => { + return record.get('emotion') + }) + return emotions + }, }, } diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index 6714c93ad..b48be16db 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -1,8 +1,14 @@ import { GraphQLClient } from 'graphql-request' +import { createTestClient } from 'apollo-server-testing' import Factory from '../../seed/factories' -import { host, login } from '../../jest/helpers' +import { host, login, gql } from '../../jest/helpers' +import { neode, getDriver } from '../../bootstrap/neo4j' +import createServer from '../../server' +const driver = getDriver() const factory = Factory() +const instance = neode() + let client let userParams let authorParams @@ -14,7 +20,7 @@ const oldContent = 'Old content' const newTitle = 'New title' const newContent = 'New content' const createPostVariables = { title: postTitle, content: postContent } -const createPostWithCategoriesMutation = ` +const createPostWithCategoriesMutation = gql` mutation($title: String!, $content: String!, $categoryIds: [ID]) { CreatePost(title: $title, content: $content, categoryIds: $categoryIds) { id @@ -27,7 +33,7 @@ const createPostWithCategoriesVariables = { content: postContent, categoryIds: ['cat9', 'cat4', 'cat15'], } -const postQueryWithCategories = ` +const postQueryWithCategories = gql` query($id: ID) { Post(id: $id) { categories { @@ -41,9 +47,9 @@ const createPostWithoutCategoriesVariables = { content: 'I should be able to filter it out', categoryIds: null, } -const postQueryFilteredByCategory = ` -query Post($filter: _PostFilter) { - Post(filter: $filter) { +const postQueryFilteredByCategory = gql` + query Post($filter: _PostFilter) { + Post(filter: $filter) { title id categories { @@ -56,13 +62,28 @@ const postCategoriesFilterParam = { categories_some: { id_in: ['cat4'] } } const postQueryFilteredByCategoryVariables = { filter: postCategoriesFilterParam, } + +const createPostMutation = gql` + mutation($title: String!, $content: String!) { + CreatePost(title: $title, content: $content) { + id + title + content + slug + disabled + deleted + } + } +` beforeEach(async () => { userParams = { + id: 'u198', name: 'TestUser', email: 'test@example.org', password: '1234', } authorParams = { + id: 'u25', email: 'author@example.org', password: '1234', } @@ -74,22 +95,12 @@ afterEach(async () => { }) describe('CreatePost', () => { - const mutation = ` - mutation($title: String!, $content: String!) { - CreatePost(title: $title, content: $content) { - title - content - slug - disabled - deleted - } - } - ` - describe('unauthenticated', () => { it('throws authorization error', async () => { client = new GraphQLClient(host) - await expect(client.request(mutation, createPostVariables)).rejects.toThrow('Not Authorised') + await expect(client.request(createPostMutation, createPostVariables)).rejects.toThrow( + 'Not Authorised', + ) }) }) @@ -107,19 +118,23 @@ describe('CreatePost', () => { content: postContent, }, } - await expect(client.request(mutation, createPostVariables)).resolves.toMatchObject(expected) + await expect(client.request(createPostMutation, createPostVariables)).resolves.toMatchObject( + expected, + ) }) it('assigns the authenticated user as author', async () => { - await client.request(mutation, createPostVariables) + await client.request(createPostMutation, createPostVariables) const { User } = await client.request( - `{ - User(name: "TestUser") { - contributions { - title + gql` + { + User(name: "TestUser") { + contributions { + title + } } } - }`, + `, { headers }, ) expect(User).toEqual([{ contributions: [{ title: postTitle }] }]) @@ -128,13 +143,15 @@ describe('CreatePost', () => { describe('disabled and deleted', () => { it('initially false', async () => { const expected = { CreatePost: { disabled: false, deleted: false } } - await expect(client.request(mutation, createPostVariables)).resolves.toMatchObject(expected) + await expect( + client.request(createPostMutation, createPostVariables), + ).resolves.toMatchObject(expected) }) }) describe('language', () => { it('allows a user to set the language of the post', async () => { - const createPostWithLanguageMutation = ` + const createPostWithLanguageMutation = gql` mutation($title: String!, $content: String!, $language: String) { CreatePost(title: $title, content: $content, language: $language) { language @@ -222,7 +239,7 @@ describe('UpdatePost', () => { title: oldTitle, content: oldContent, }) - updatePostMutation = ` + updatePostMutation = gql` mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID]) { UpdatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) { id @@ -328,7 +345,7 @@ describe('UpdatePost', () => { }) describe('DeletePost', () => { - const mutation = ` + const mutation = gql` mutation($id: ID!) { DeletePost(id: $id) { id @@ -383,3 +400,315 @@ describe('DeletePost', () => { }) }) }) + +describe('emotions', () => { + let addPostEmotionsVariables, + someUser, + ownerNode, + owner, + postMutationAction, + user, + postQueryAction, + postToEmote, + postToEmoteNode + const PostsEmotionsCountQuery = ` + query($id: ID!) { + Post(id: $id) { + emotionsCount + } + } + ` + const PostsEmotionsQuery = gql` + query($id: ID!) { + Post(id: $id) { + emotions { + emotion + User { + id + } + } + } + } + ` + const addPostEmotionsMutation = gql` + mutation($to: _PostInput!, $data: _EMOTEDInput!) { + AddPostEmotions(to: $to, data: $data) { + from { + id + } + to { + id + } + emotion + } + } + ` + beforeEach(async () => { + userParams.id = 'u1987' + authorParams.id = 'u257' + createPostVariables.id = 'p1376' + const someUserNode = await instance.create('User', userParams) + someUser = await someUserNode.toJson() + ownerNode = await instance.create('User', authorParams) + owner = await ownerNode.toJson() + postToEmoteNode = await instance.create('Post', createPostVariables) + postToEmote = await postToEmoteNode.toJson() + await postToEmoteNode.relateTo(ownerNode, 'author') + + postMutationAction = async (user, mutation, variables) => { + const { server } = createServer({ + context: () => { + return { + user, + driver, + } + }, + }) + const { mutate } = createTestClient(server) + + return mutate({ + mutation, + variables, + }) + } + postQueryAction = async (postQuery, variables) => { + const { server } = createServer({ + context: () => { + return { + user, + driver, + } + }, + }) + const { query } = createTestClient(server) + return query({ query: postQuery, variables }) + } + addPostEmotionsVariables = { + to: { id: postToEmote.id }, + data: { emotion: 'happy' }, + } + }) + + describe('AddPostEmotions', () => { + let postsEmotionsQueryVariables + beforeEach(async () => { + postsEmotionsQueryVariables = { id: postToEmote.id } + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + user = null + const addPostEmotions = await postMutationAction( + user, + addPostEmotionsMutation, + addPostEmotionsVariables, + ) + + expect(addPostEmotions.errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + }) + + describe('authenticated and not the author', () => { + beforeEach(() => { + user = someUser + }) + + it('adds an emotion to the post', async () => { + const expected = { + data: { + AddPostEmotions: { + from: { id: user.id }, + to: addPostEmotionsVariables.to, + emotion: 'happy', + }, + }, + } + await expect( + postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables), + ).resolves.toEqual(expect.objectContaining(expected)) + }) + + it('limits the addition of the same emotion to 1', async () => { + const expected = { + data: { + Post: [ + { + emotionsCount: 1, + }, + ], + }, + } + await postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables) + await postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables) + await expect( + postQueryAction(PostsEmotionsCountQuery, postsEmotionsQueryVariables), + ).resolves.toEqual(expect.objectContaining(expected)) + }) + + it('allows a user to add more than one emotion', async () => { + const expectedEmotions = [ + { emotion: 'happy', User: { id: user.id } }, + { emotion: 'surprised', User: { id: user.id } }, + ] + const expectedResponse = { + data: { Post: [{ emotions: expect.arrayContaining(expectedEmotions) }] }, + } + await postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables) + addPostEmotionsVariables.data.emotion = 'surprised' + await postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables) + await expect( + postQueryAction(PostsEmotionsQuery, postsEmotionsQueryVariables), + ).resolves.toEqual(expect.objectContaining(expectedResponse)) + }) + }) + + describe('authenticated as author', () => { + beforeEach(() => { + user = owner + }) + + it('adds an emotion to the post', async () => { + const expected = { + data: { + AddPostEmotions: { + from: { id: owner.id }, + to: addPostEmotionsVariables.to, + emotion: 'happy', + }, + }, + } + await expect( + postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables), + ).resolves.toEqual(expect.objectContaining(expected)) + }) + }) + }) + + describe('RemovePostEmotions', () => { + let removePostEmotionsVariables, postsEmotionsQueryVariables + const removePostEmotionsMutation = gql` + mutation($to: _PostInput!, $data: _EMOTEDInput!) { + RemovePostEmotions(to: $to, data: $data) { + from { + id + } + to { + id + } + emotion + } + } + ` + beforeEach(async () => { + await ownerNode.relateTo(postToEmoteNode, 'emoted', { emotion: 'cry' }) + await postMutationAction(user, addPostEmotionsMutation, addPostEmotionsVariables) + + postsEmotionsQueryVariables = { id: postToEmote.id } + removePostEmotionsVariables = { + to: { id: postToEmote.id }, + data: { emotion: 'cry' }, + } + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + user = null + const removePostEmotions = await postMutationAction( + user, + removePostEmotionsMutation, + removePostEmotionsVariables, + ) + expect(removePostEmotions.errors[0]).toHaveProperty('message', 'Not Authorised!') + }) + }) + + describe('authenticated', () => { + describe('but not the emoter', () => { + it('returns null if the emotion could not be found', async () => { + user = someUser + const removePostEmotions = await postMutationAction( + user, + removePostEmotionsMutation, + removePostEmotionsVariables, + ) + expect(removePostEmotions).toEqual( + expect.objectContaining({ data: { RemovePostEmotions: null } }), + ) + }) + }) + + describe('as the emoter', () => { + it('removes an emotion from a post', async () => { + user = owner + const expected = { + data: { + RemovePostEmotions: { + to: { id: postToEmote.id }, + from: { id: user.id }, + emotion: 'cry', + }, + }, + } + await expect( + postMutationAction(user, removePostEmotionsMutation, removePostEmotionsVariables), + ).resolves.toEqual(expect.objectContaining(expected)) + }) + + it('removes only the requested emotion, not all emotions', async () => { + const expectedEmotions = [{ emotion: 'happy', User: { id: authorParams.id } }] + const expectedResponse = { + data: { Post: [{ emotions: expect.arrayContaining(expectedEmotions) }] }, + } + await postMutationAction(user, removePostEmotionsMutation, removePostEmotionsVariables) + await expect( + postQueryAction(PostsEmotionsQuery, postsEmotionsQueryVariables), + ).resolves.toEqual(expect.objectContaining(expectedResponse)) + }) + }) + }) + }) + + describe('posts emotions count', () => { + let PostsEmotionsCountByEmotionVariables + let PostsEmotionsByCurrentUserVariables + + const PostsEmotionsCountByEmotionQuery = gql` + query($postId: ID!, $data: _EMOTEDInput!) { + PostsEmotionsCountByEmotion(postId: $postId, data: $data) + } + ` + + const PostsEmotionsByCurrentUserQuery = gql` + query($postId: ID!) { + PostsEmotionsByCurrentUser(postId: $postId) + } + ` + beforeEach(async () => { + await ownerNode.relateTo(postToEmoteNode, 'emoted', { emotion: 'cry' }) + + PostsEmotionsCountByEmotionVariables = { + postId: postToEmote.id, + data: { emotion: 'cry' }, + } + PostsEmotionsByCurrentUserVariables = { postId: postToEmote.id } + }) + + describe('PostsEmotionsCountByEmotion', () => { + it("returns a post's emotions count", async () => { + const expectedResponse = { data: { PostsEmotionsCountByEmotion: 1 } } + await expect( + postQueryAction(PostsEmotionsCountByEmotionQuery, PostsEmotionsCountByEmotionVariables), + ).resolves.toEqual(expect.objectContaining(expectedResponse)) + }) + }) + + describe('PostsEmotionsCountByEmotion', () => { + it("returns a currentUser's emotions on a post", async () => { + const expectedResponse = { data: { PostsEmotionsByCurrentUser: ['cry'] } } + await expect( + postQueryAction(PostsEmotionsByCurrentUserQuery, PostsEmotionsByCurrentUserVariables), + ).resolves.toEqual(expect.objectContaining(expectedResponse)) + }) + }) + }) +}) diff --git a/backend/src/schema/types/type/EMOTED.gql b/backend/src/schema/types/type/EMOTED.gql index 80d655b5c..d40eed0c4 100644 --- a/backend/src/schema/types/type/EMOTED.gql +++ b/backend/src/schema/types/type/EMOTED.gql @@ -7,4 +7,4 @@ type EMOTED @relation(name: "EMOTED") { #updatedAt: DateTime createdAt: String updatedAt: String -} \ No newline at end of file +} diff --git a/backend/src/schema/types/type/Post.gql b/backend/src/schema/types/type/Post.gql index d254a9a9c..519af14ae 100644 --- a/backend/src/schema/types/type/Post.gql +++ b/backend/src/schema/types/type/Post.gql @@ -50,6 +50,8 @@ type Post { ) emotions: [EMOTED] + emotionsCount: Int! + @cypher(statement: "MATCH (this)<-[emoted:EMOTED]-(:User) RETURN COUNT(DISTINCT emoted)") } type Mutation { @@ -89,4 +91,11 @@ type Mutation { language: String categoryIds: [ID] ): Post + AddPostEmotions(to: _PostInput!, data: _EMOTEDInput!): EMOTED + RemovePostEmotions(to: _PostInput!, data: _EMOTEDInput!): EMOTED +} + +type Query { + PostsEmotionsCountByEmotion(postId: ID!, data: _EMOTEDInput!): Int! + PostsEmotionsByCurrentUser(postId: ID!): [String] } diff --git a/backend/src/seed/factories/index.js b/backend/src/seed/factories/index.js index 0efb2a886..df3886a6c 100644 --- a/backend/src/seed/factories/index.js +++ b/backend/src/seed/factories/index.js @@ -128,6 +128,22 @@ export default function Factory(options = {}) { this.lastResponse = await cleanDatabase({ driver: this.neo4jDriver }) return this }, + async emote({ to, data }) { + const mutation = ` + mutation { + AddPostEmotions( + to: { id: "${to}" }, + data: { emotion: ${data} } + ) { + from { id } + to { id } + emotion + } + } + ` + this.lastResponse = await this.graphQLClient.request(mutation) + return this + }, } result.authenticateAs.bind(result) result.create.bind(result) diff --git a/backend/src/seed/seed-db.js b/backend/src/seed/seed-db.js index 6d32b64df..c3a05248d 100644 --- a/backend/src/seed/seed-db.js +++ b/backend/src/seed/seed-db.js @@ -488,6 +488,116 @@ import Factory from './factories' from: 'p15', to: 'Demokratie', }), + f.emote({ + from: 'u1', + to: 'p15', + data: 'surprised', + }), + f.emote({ + from: 'u2', + to: 'p15', + data: 'surprised', + }), + f.emote({ + from: 'u3', + to: 'p15', + data: 'surprised', + }), + f.emote({ + from: 'u4', + to: 'p15', + data: 'surprised', + }), + f.emote({ + from: 'u5', + to: 'p15', + data: 'surprised', + }), + f.emote({ + from: 'u6', + to: 'p15', + data: 'surprised', + }), + f.emote({ + from: 'u7', + to: 'p15', + data: 'surprised', + }), + f.emote({ + from: 'u2', + to: 'p14', + data: 'cry', + }), + f.emote({ + from: 'u3', + to: 'p13', + data: 'angry', + }), + f.emote({ + from: 'u4', + to: 'p12', + data: 'funny', + }), + f.emote({ + from: 'u5', + to: 'p11', + data: 'surprised', + }), + f.emote({ + from: 'u6', + to: 'p10', + data: 'cry', + }), + f.emote({ + from: 'u5', + to: 'p9', + data: 'happy', + }), + f.emote({ + from: 'u4', + to: 'p8', + data: 'angry', + }), + f.emote({ + from: 'u3', + to: 'p7', + data: 'funny', + }), + f.emote({ + from: 'u2', + to: 'p6', + data: 'surprised', + }), + f.emote({ + from: 'u1', + to: 'p5', + data: 'cry', + }), + f.emote({ + from: 'u2', + to: 'p4', + data: 'happy', + }), + f.emote({ + from: 'u3', + to: 'p3', + data: 'angry', + }), + f.emote({ + from: 'u4', + to: 'p2', + data: 'funny', + }), + f.emote({ + from: 'u5', + to: 'p1', + data: 'surprised', + }), + f.emote({ + from: 'u6', + to: 'p0', + data: 'cry', + }), ]) await Promise.all([ diff --git a/webapp/components/Emotions/Emotions.spec.js b/webapp/components/Emotions/Emotions.spec.js new file mode 100644 index 000000000..ecc7f9e94 --- /dev/null +++ b/webapp/components/Emotions/Emotions.spec.js @@ -0,0 +1,127 @@ +import { mount, createLocalVue } from '@vue/test-utils' +import Emotions from './Emotions.vue' +import Styleguide from '@human-connection/styleguide' +import Vuex from 'vuex' +import PostMutations from '~/graphql/PostMutations.js' + +const localVue = createLocalVue() + +localVue.use(Styleguide) +localVue.use(Vuex) + +describe('Emotions.vue', () => { + let wrapper + let mocks + let propsData + let getters + let funnyButton + let funnyImage + const funnyImageSrc = '/img/svg/emoji/funny_color.svg' + + beforeEach(() => { + mocks = { + $apollo: { + mutate: jest + .fn() + .mockResolvedValueOnce({ + data: { + AddPostEmotions: { + to: { id: 'p143' }, + data: { emotion: 'happy' }, + }, + }, + }) + .mockResolvedValueOnce({ + data: { + RemovePostEmotions: { + from: { id: 'u176' }, + to: { id: 'p143' }, + data: { emotion: 'happy' }, + }, + }, + }), + query: jest.fn().mockResolvedValue({ + data: { + PostsEmotionsCountByEmotion: 1, + }, + }), + }, + $t: jest.fn(), + } + propsData = { + post: { id: 'p143' }, + } + getters = { + 'auth/user': () => { + return { id: 'u176' } + }, + } + }) + describe('mount', () => { + const Wrapper = () => { + const store = new Vuex.Store({ + getters, + }) + return mount(Emotions, { mocks, propsData, store, localVue }) + } + beforeEach(() => { + wrapper = Wrapper() + }) + + it("queries the post's emotions count for each of the 5 emotions", () => { + expect(mocks.$apollo.query).toHaveBeenCalledTimes(5) + }) + + describe('adding emotions', () => { + let expectedParams + beforeEach(() => { + wrapper.vm.PostsEmotionsCountByEmotion.funny = 0 + funnyButton = wrapper.findAll('button').at(0) + funnyButton.trigger('click') + }) + + it('shows the colored image when the button is active', () => { + funnyImage = wrapper.findAll('img').at(0) + expect(funnyImage.attributes().src).toEqual(funnyImageSrc) + }) + + it('sends the AddPostEmotionsMutation for an emotion when clicked', () => { + expectedParams = { + mutation: PostMutations().AddPostEmotionsMutation, + variables: { to: { id: 'p143' }, data: { emotion: 'funny' } }, + } + expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) + }) + + it('increases the PostsEmotionsCountByEmotion for the emotion clicked', () => { + expect(wrapper.vm.PostsEmotionsCountByEmotion.funny).toEqual(1) + }) + + it('adds an emotion to selectedEmotions to show the colored image when the button is active', () => { + expect(wrapper.vm.selectedEmotions).toEqual(['funny']) + }) + + describe('removing emotions', () => { + beforeEach(() => { + funnyButton.trigger('click') + }) + + it('sends the RemovePostEmotionsMutation when a user clicks on an active emotion', () => { + expectedParams = { + mutation: PostMutations().RemovePostEmotionsMutation, + variables: { to: { id: 'p143' }, data: { emotion: 'funny' } }, + } + expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) + }) + + it('decreases the PostsEmotionsCountByEmotion for the emotion clicked', async () => { + expect(wrapper.vm.PostsEmotionsCountByEmotion.funny).toEqual(0) + }) + + it('removes an emotion from selectedEmotions to show the default image', async () => { + expect(wrapper.vm.selectedEmotions).toEqual([]) + }) + }) + }) + }) +}) diff --git a/webapp/components/Emotions/Emotions.vue b/webapp/components/Emotions/Emotions.vue new file mode 100644 index 000000000..3be7ee790 --- /dev/null +++ b/webapp/components/Emotions/Emotions.vue @@ -0,0 +1,115 @@ + + + + + + + + + + diff --git a/webapp/components/EmotionsButton/EmotionsButton.vue b/webapp/components/EmotionsButton/EmotionsButton.vue new file mode 100644 index 000000000..b4849c31a --- /dev/null +++ b/webapp/components/EmotionsButton/EmotionsButton.vue @@ -0,0 +1,50 @@ + + + + + + + + {{ $t(`contribution.emotions-label.${emotion}`) }} + + {{ PostsEmotionsCountByEmotion[emotion] }}x + + {{ $t('contribution.emotions-label.emoted') }} + + + + + diff --git a/webapp/components/ShoutButton.vue b/webapp/components/ShoutButton.vue index 3f24ee6c8..94d76005b 100644 --- a/webapp/components/ShoutButton.vue +++ b/webapp/components/ShoutButton.vue @@ -1,5 +1,5 @@ - + diff --git a/webapp/graphql/PostMutations.js b/webapp/graphql/PostMutations.js index e1164bb5a..fc672c40d 100644 --- a/webapp/graphql/PostMutations.js +++ b/webapp/graphql/PostMutations.js @@ -60,5 +60,31 @@ export default () => { } } `, + AddPostEmotionsMutation: gql` + mutation($to: _PostInput!, $data: _EMOTEDInput!) { + AddPostEmotions(to: $to, data: $data) { + emotion + from { + id + } + to { + id + } + } + } + `, + RemovePostEmotionsMutation: gql` + mutation($to: _PostInput!, $data: _EMOTEDInput!) { + RemovePostEmotions(to: $to, data: $data) { + emotion + from { + id + } + to { + id + } + } + } + `, } } diff --git a/webapp/graphql/PostQuery.js b/webapp/graphql/PostQuery.js index eec2b25d8..9d54a5d2b 100644 --- a/webapp/graphql/PostQuery.js +++ b/webapp/graphql/PostQuery.js @@ -71,6 +71,7 @@ export default i18n => { } shoutedCount shoutedByCurrentUser + emotionsCount } } ` @@ -120,3 +121,11 @@ export const filterPosts = i18n => { } ` } + +export const PostsEmotionsByCurrentUser = () => { + return gql` + query PostsEmotionsByCurrentUser($postId: ID!) { + PostsEmotionsByCurrentUser(postId: $postId) + } + ` +} diff --git a/webapp/locales/de.json b/webapp/locales/de.json index f94ea693c..ae292916e 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -420,6 +420,14 @@ "languageSelectLabel": "Sprache", "categories": { "infoSelectedNoOfMaxCategories": "{chosen} von {max} Kategorien ausgewählt" + }, + "emotions-label": { + "funny": "Lustig", + "happy": "Glücklich", + "surprised": "Erstaunt", + "cry": "Zum Weinen", + "angry": "Verärgert", + "emoted": "angegeben" } }, "changelog": { diff --git a/webapp/locales/en.json b/webapp/locales/en.json index c073f312a..7e70a5600 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -420,6 +420,14 @@ "languageSelectLabel": "Language", "categories": { "infoSelectedNoOfMaxCategories": "{chosen} of {max} categories selected" + }, + "emotions-label": { + "funny": "Funny", + "happy": "Happy", + "surprised": "Surprised", + "cry": "Cry", + "angry": "Angry", + "emoted": "emoted" } }, "changelog": { diff --git a/webapp/pages/post/_id/_slug/index.spec.js b/webapp/pages/post/_id/_slug/index.spec.js index 7a2c398b6..13b0b3b82 100644 --- a/webapp/pages/post/_id/_slug/index.spec.js +++ b/webapp/pages/post/_id/_slug/index.spec.js @@ -31,7 +31,7 @@ describe('PostSlug', () => { $filters: { truncate: a => a, }, - // If you mocking router, than don't use VueRouter with lacalVue: https://vue-test-utils.vuejs.org/guides/using-with-vue-router.html + // If you are mocking the router, then don't use VueRouter with localVue: https://vue-test-utils.vuejs.org/guides/using-with-vue-router.html $router: { history: { push: jest.fn(), diff --git a/webapp/pages/post/_id/_slug/index.vue b/webapp/pages/post/_id/_slug/index.vue index aa0d95514..6a39c7d81 100644 --- a/webapp/pages/post/_id/_slug/index.vue +++ b/webapp/pages/post/_id/_slug/index.vue @@ -40,14 +40,30 @@ - - + + + + + + + + + + + + @@ -69,6 +85,7 @@ import HcCommentForm from '~/components/comments/CommentForm' import HcCommentList from '~/components/comments/CommentList' import { postMenuModalsData, deletePostMutation } from '~/components/utils/PostHelpers' import PostQuery from '~/graphql/PostQuery.js' +import HcEmotions from '~/components/Emotions/Emotions' export default { name: 'PostSlug', @@ -84,6 +101,7 @@ export default { ContentMenu, HcCommentForm, HcCommentList, + HcEmotions, ContentViewer, }, head() { @@ -198,4 +216,9 @@ export default { } } } +@media only screen and (max-width: 960px) { + .shout-button { + float: left; + } +} diff --git a/webapp/static/img/svg/emoji/angry.svg b/webapp/static/img/svg/emoji/angry.svg new file mode 100644 index 000000000..74abe161f --- /dev/null +++ b/webapp/static/img/svg/emoji/angry.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/static/img/svg/emoji/angry_color.svg b/webapp/static/img/svg/emoji/angry_color.svg new file mode 100644 index 000000000..f6b4bd9a8 --- /dev/null +++ b/webapp/static/img/svg/emoji/angry_color.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/static/img/svg/emoji/cry.svg b/webapp/static/img/svg/emoji/cry.svg new file mode 100644 index 000000000..d375fd2fd --- /dev/null +++ b/webapp/static/img/svg/emoji/cry.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/static/img/svg/emoji/cry_color.svg b/webapp/static/img/svg/emoji/cry_color.svg new file mode 100644 index 000000000..6a32bc2c5 --- /dev/null +++ b/webapp/static/img/svg/emoji/cry_color.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/static/img/svg/emoji/funny.svg b/webapp/static/img/svg/emoji/funny.svg new file mode 100644 index 000000000..d23792d8c --- /dev/null +++ b/webapp/static/img/svg/emoji/funny.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/static/img/svg/emoji/funny_color.svg b/webapp/static/img/svg/emoji/funny_color.svg new file mode 100644 index 000000000..3ac2087e8 --- /dev/null +++ b/webapp/static/img/svg/emoji/funny_color.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/static/img/svg/emoji/happy.svg b/webapp/static/img/svg/emoji/happy.svg new file mode 100644 index 000000000..d0d8a4e80 --- /dev/null +++ b/webapp/static/img/svg/emoji/happy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/static/img/svg/emoji/happy_color.svg b/webapp/static/img/svg/emoji/happy_color.svg new file mode 100644 index 000000000..d541639e3 --- /dev/null +++ b/webapp/static/img/svg/emoji/happy_color.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/static/img/svg/emoji/surprised.svg b/webapp/static/img/svg/emoji/surprised.svg new file mode 100644 index 000000000..8a02a5a50 --- /dev/null +++ b/webapp/static/img/svg/emoji/surprised.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/static/img/svg/emoji/surprised_color.svg b/webapp/static/img/svg/emoji/surprised_color.svg new file mode 100644 index 000000000..398c34f35 --- /dev/null +++ b/webapp/static/img/svg/emoji/surprised_color.svg @@ -0,0 +1 @@ + \ No newline at end of file
{{ $t(`contribution.emotions-label.${emotion}`) }}
+ {{ PostsEmotionsCountByEmotion[emotion] }}x +