diff --git a/backend/src/factories/posts.js b/backend/src/factories/posts.js index 3295665b7..d997b738f 100644 --- a/backend/src/factories/posts.js +++ b/backend/src/factories/posts.js @@ -55,7 +55,19 @@ export default function create() { if (authorId) author = await neodeInstance.find('User', authorId) author = author || (await factoryInstance.create('User')) const post = await neodeInstance.create('Post', args) + + const { commentContent } = args + let comment + delete args.commentContent + if (commentContent) + comment = await factoryInstance.create('Comment', { + contentExcerpt: commentContent, + post, + author, + }) + await post.relateTo(author, 'author') + if (comment) await post.relateTo(comment, 'comments') if (args.pinned) { args.pinnedAt = args.pinnedAt || new Date().toISOString() diff --git a/backend/src/models/Post.js b/backend/src/models/Post.js index 2b553232e..154456cf1 100644 --- a/backend/src/models/Post.js +++ b/backend/src/models/Post.js @@ -41,6 +41,16 @@ export default { language: { type: 'string', allow: [null] }, imageBlurred: { type: 'boolean', default: false }, imageAspectRatio: { type: 'float', default: 1.0 }, + comments: { + type: 'relationship', + relationship: 'COMMENTS', + target: 'Comment', + direction: 'in', + properties: { + createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + updatedAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + }, + }, pinned: { type: 'boolean', default: null, valid: [null, true] }, pinnedAt: { type: 'string', isoDate: true }, } diff --git a/cypress/integration/common/post.js b/cypress/integration/common/post.js index c4da93c7d..39407ef4f 100644 --- a/cypress/integration/common/post.js +++ b/cypress/integration/common/post.js @@ -17,8 +17,13 @@ Then("I click on the {string} button", text => { .click(); }); +Then("I click on the reply button", () => { + cy.get(".reply-button") + .click(); +}); + Then("my comment should be successfully created", () => { - cy.get(".iziToast-message").contains("Comment Submitted"); + cy.get(".iziToast-message").contains("Comment submitted!"); }); Then("I should see my comment", () => { @@ -45,6 +50,12 @@ Then("the editor should be cleared", () => { cy.get(".ProseMirror p").should("have.class", "is-empty"); }); +Then("it should create a mention in the CommentForm", () => { + cy.get(".ProseMirror a") + .should('have.class', 'mention') + .should('contain', '@peter-pan') +}) + When("I open the content menu of post {string}", (title)=> { cy.contains('.post-card', title) .find('.content-menu .base-button') diff --git a/cypress/integration/post/Comment.feature b/cypress/integration/post/Comment.feature index 50284d6f5..66cf7a6d7 100644 --- a/cypress/integration/post/Comment.feature +++ b/cypress/integration/post/Comment.feature @@ -4,10 +4,10 @@ Feature: Post Comment To be able to express my thoughts and emotions about these, discuss, and add give further information. Background: - Given we have the following posts in our database: - | id | title | slug | - | bWBjpkTKZp | 101 Essays that will change the way you think | 101-essays | - And I have a user account + Given I have a user account + And we have the following posts in our database: + | id | title | slug | authorId | commentContent | + | bWBjpkTKZp | 101 Essays that will change the way you think | 101-essays | id-of-peter-pan | @peter-pan reply to me | And I am logged in Scenario: Comment creation @@ -36,3 +36,8 @@ Feature: Post Comment Then my comment should be successfully created And I should see an abreviated version of my comment And the editor should be cleared + + Scenario: Direct reply to Comment + Given I visit "post/bWBjpkTKZp/101-essays" + And I click on the reply button + Then it should create a mention in the CommentForm diff --git a/cypress/support/commands.js b/cypress/support/commands.js index c9a2e213a..16ac43a19 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -15,7 +15,6 @@ /* globals Cypress cy */ import "cypress-file-upload"; import helpers from "./helpers"; -import users from "../fixtures/users.json"; import { GraphQLClient, request } from 'graphql-request' import { gql } from '../../backend/src/helpers/jest' import config from '../../backend/src/config' diff --git a/webapp/assets/_new/icons/svgs/level-down.svg b/webapp/assets/_new/icons/svgs/level-down.svg new file mode 100755 index 000000000..e6455391e --- /dev/null +++ b/webapp/assets/_new/icons/svgs/level-down.svg @@ -0,0 +1,5 @@ + + +level-down + + diff --git a/webapp/components/Comment/Comment.spec.js b/webapp/components/Comment/Comment.spec.js index b307700d9..1ba238bf5 100644 --- a/webapp/components/Comment/Comment.spec.js +++ b/webapp/components/Comment/Comment.spec.js @@ -1,17 +1,15 @@ -import { config, shallowMount } from '@vue/test-utils' +import { config, mount } from '@vue/test-utils' import Comment from './Comment.vue' import Vuex from 'vuex' const localVue = global.localVue +localVue.directive('scrollTo', jest.fn()) config.stubs['client-only'] = '' +config.stubs['nuxt-link'] = '' describe('Comment.vue', () => { - let propsData - let mocks - let getters - let wrapper - let Wrapper + let propsData, mocks, stubs, getters, wrapper, Wrapper beforeEach(() => { propsData = {} @@ -39,6 +37,9 @@ describe('Comment.vue', () => { }), }, } + stubs = { + ContentViewer: true, + } getters = { 'auth/user': () => { return {} @@ -47,18 +48,19 @@ describe('Comment.vue', () => { } }) - describe('shallowMount', () => { + describe('mount', () => { beforeEach(jest.useFakeTimers) Wrapper = () => { const store = new Vuex.Store({ getters, }) - return shallowMount(Comment, { + return mount(Comment, { store, propsData, mocks, localVue, + stubs, }) } @@ -68,6 +70,7 @@ describe('Comment.vue', () => { id: '2', contentExcerpt: 'Hello I am a comment content', content: 'Hello I am comment content', + author: { id: 'commentAuthorId', slug: 'ogerly' }, } }) @@ -199,6 +202,24 @@ describe('Comment.vue', () => { }) }) }) + + describe('click reply button', () => { + beforeEach(async () => { + wrapper = Wrapper() + await wrapper.find('.reply-button').trigger('click') + }) + + it('emits "reply"', () => { + expect(wrapper.emitted('reply')).toEqual([ + [ + { + id: 'commentAuthorId', + slug: 'ogerly', + }, + ], + ]) + }) + }) }) }) }) diff --git a/webapp/components/Comment/Comment.vue b/webapp/components/Comment/Comment.vue index 78f37c1ea..5c47a3656 100644 --- a/webapp/components/Comment/Comment.vue +++ b/webapp/components/Comment/Comment.vue @@ -54,6 +54,15 @@ + @@ -67,6 +76,7 @@ import ContentViewer from '~/components/Editor/ContentViewer' import CommentForm from '~/components/CommentForm/CommentForm' import CommentMutations from '~/graphql/CommentMutations' import scrollToAnchor from '~/mixins/scrollToAnchor.js' +import BaseButton from '~/components/_new/generic/BaseButton/BaseButton' export default { mixins: [scrollToAnchor], @@ -86,6 +96,7 @@ export default { ContentMenu, ContentViewer, CommentForm, + BaseButton, }, props: { routeHash: { type: String, default: () => '' }, @@ -105,7 +116,6 @@ export default { if (this.isLongComment && this.isCollapsed) { return this.$filters.truncate(this.comment.content, COMMENT_TRUNCATE_TO_LENGTH) } - return this.comment.content }, displaysComment() { @@ -141,6 +151,10 @@ export default { }, }, methods: { + reply() { + const message = { slug: this.comment.author.slug, id: this.comment.author.id } + this.$emit('reply', message) + }, checkAnchor(anchor) { return `#${this.anchor}` === anchor }, @@ -193,6 +207,14 @@ export default { float: right; } +.reply-button { + float: right; + top: 0px; +} +.reply-button:after { + clear: both; +} + @keyframes highlight { 0% { border: 1px solid $color-primary; diff --git a/webapp/components/CommentForm/CommentForm.vue b/webapp/components/CommentForm/CommentForm.vue index 063a3d599..9e4158876 100644 --- a/webapp/components/CommentForm/CommentForm.vue +++ b/webapp/components/CommentForm/CommentForm.vue @@ -51,6 +51,9 @@ export default { } }, methods: { + reply(message) { + this.$refs.editor.insertReply(message) + }, updateEditorContent(value) { const sanitizedContent = this.$filters.removeHtml(value, false) if (!this.update) { @@ -133,8 +136,8 @@ export default { query() { return minimisedUserQuery() }, - result(result) { - this.users = result.data.User + update({ User }) { + this.users = User }, }, }, diff --git a/webapp/components/CommentList/CommentList.spec.js b/webapp/components/CommentList/CommentList.spec.js index 064b8f136..ac7b88c0e 100644 --- a/webapp/components/CommentList/CommentList.spec.js +++ b/webapp/components/CommentList/CommentList.spec.js @@ -1,10 +1,13 @@ import { config, mount } from '@vue/test-utils' import CommentList from './CommentList' +import Comment from '~/components/Comment/Comment' import Vuex from 'vuex' +import Vue from 'vue' const localVue = global.localVue localVue.filter('truncate', string => string) +localVue.directive('scrollTo', jest.fn()) config.stubs['v-popover'] = '' config.stubs['nuxt-link'] = '' @@ -97,5 +100,27 @@ describe('CommentList.vue', () => { }) }) }) + + describe('Comment', () => { + beforeEach(() => { + wrapper = Wrapper() + }) + + it('Comment emitted reply()', () => { + wrapper.find(Comment).vm.$emit('reply', { + id: 'commentAuthorId', + slug: 'ogerly', + }) + Vue.nextTick() + expect(wrapper.emitted('reply')).toEqual([ + [ + { + id: 'commentAuthorId', + slug: 'ogerly', + }, + ], + ]) + }) + }) }) }) diff --git a/webapp/components/CommentList/CommentList.vue b/webapp/components/CommentList/CommentList.vue index 96edf9bc3..ef6e3b096 100644 --- a/webapp/components/CommentList/CommentList.vue +++ b/webapp/components/CommentList/CommentList.vue @@ -12,9 +12,11 @@ :comment="comment" :post="post" :routeHash="routeHash" + class="comment-tag" @deleteComment="updateCommentList" @updateComment="updateCommentList" @toggleNewCommentForm="toggleNewCommentForm" + @reply="reply" /> @@ -35,6 +37,9 @@ export default { post: { type: Object, default: () => {} }, }, methods: { + reply(message) { + this.$emit('reply', message) + }, checkAnchor(anchor) { return anchor === '#comments' }, diff --git a/webapp/components/Editor/Editor.vue b/webapp/components/Editor/Editor.vue index 235437c32..44ed0d15e 100644 --- a/webapp/components/Editor/Editor.vue +++ b/webapp/components/Editor/Editor.vue @@ -141,7 +141,6 @@ export default { methods: { openSuggestionList({ items, query, range, command, virtualNode }, suggestionType) { this.suggestionType = suggestionType - this.query = this.sanitizeQuery(query) this.filteredItems = items this.suggestionRange = range @@ -237,6 +236,9 @@ export default { const content = e.getHTML() this.$emit('input', content) }, + insertReply(message) { + this.editor.commands.mention({ id: message.id, label: message.slug }) + }, toggleLinkInput(attrs, element) { if (!this.isLinkInputActive && attrs && element) { this.$refs.linkInput.linkUrl = attrs.href diff --git a/webapp/components/Emotions/Emotions.vue b/webapp/components/Emotions/Emotions.vue index 96389d10d..e4475c46b 100644 --- a/webapp/components/Emotions/Emotions.vue +++ b/webapp/components/Emotions/Emotions.vue @@ -105,7 +105,7 @@ export default { postId: this.post.id, } }, - result({ data: { PostsEmotionsByCurrentUser } }) { + update({ PostsEmotionsByCurrentUser }) { this.selectedEmotions = PostsEmotionsByCurrentUser }, }, diff --git a/webapp/locales/de.json b/webapp/locales/de.json index e2416ffd9..d552d51ba 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -279,8 +279,9 @@ }, "comment": { "submit": "Kommentiere", - "submitted": "Kommentar gesendet", - "updated": "Änderungen gespeichert" + "submitted": "Kommentar gesendet!", + "updated": "Änderungen gespeichert", + "reply": "Antworten" }, "edited": "bearbeitet" }, diff --git a/webapp/locales/en.json b/webapp/locales/en.json index 01f10f4b1..d15614ecc 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -444,8 +444,9 @@ }, "comment": { "submit": "Comment", - "submitted": "Comment Submitted", - "updated": "Changes Saved" + "submitted": "Comment submitted!", + "updated": "Changes saved!", + "reply": "Reply" }, "edited": "edited" }, diff --git a/webapp/pages/post/_id/_slug/index.spec.js b/webapp/pages/post/_id/_slug/index.spec.js index db960bb67..6c5cb5259 100644 --- a/webapp/pages/post/_id/_slug/index.spec.js +++ b/webapp/pages/post/_id/_slug/index.spec.js @@ -1,29 +1,33 @@ -import { config, shallowMount } from '@vue/test-utils' -import PostSlug from './index.vue' +import { config, mount } from '@vue/test-utils' import Vuex from 'vuex' - -const localVue = global.localVue +import PostSlug from './index.vue' +import CommentList from '~/components/CommentList/CommentList' config.stubs['client-only'] = '' +config.stubs['nuxt-link'] = '' +config.stubs['router-link'] = '' + +const localVue = global.localVue +localVue.directive('scrollTo', jest.fn()) describe('PostSlug', () => { - let wrapper - let Wrapper - let store - let mocks + let store, propsData, mocks, stubs, wrapper, Wrapper beforeEach(() => { store = new Vuex.Store({ getters: { 'auth/user': () => { - return {} + return { id: '1stUser' } }, + 'auth/isModerator': () => false, }, }) + propsData = {} mocks = { $t: jest.fn(), $filters: { truncate: a => a, + removeHtml: a => a, }, $route: { hash: '', @@ -40,38 +44,53 @@ describe('PostSlug', () => { }, $apollo: { mutate: jest.fn().mockResolvedValue(), + query: jest.fn().mockResolvedValue({ data: { PostEmotionsCountByEmotion: {} } }), }, + $scrollTo: jest.fn(), } + stubs = { + HcEditor: { render: () => {}, methods: { insertReply: jest.fn(() => null) } }, + ContentViewer: true, + } + jest.useFakeTimers() + wrapper = Wrapper() + wrapper.setData({ + post: { + id: '1', + author: { + id: '1stUser', + }, + comments: [ + { + id: 'comment134', + contentExcerpt: 'this is a comment', + content: 'this is a comment', + author: { + id: '1stUser', + slug: '1st-user', + }, + }, + ], + }, + ready: true, + }) }) - describe('shallowMount', () => { + describe('mount', () => { Wrapper = () => { - return shallowMount(PostSlug, { + return mount(PostSlug, { store, mocks, localVue, + propsData, + stubs, }) } - beforeEach(jest.useFakeTimers) - describe('test Post callbacks', () => { - beforeEach(() => { - wrapper = Wrapper() - wrapper.setData({ - post: { - id: 'p23', - name: 'It is a post', - author: { - id: 'u1', - }, - }, - }) - }) - describe('deletion of Post from Page by invoking "deletePostCallback()"', () => { - beforeEach(() => { - wrapper.vm.deletePostCallback() + beforeEach(async () => { + await wrapper.vm.deletePostCallback() }) describe('after timeout', () => { @@ -91,5 +110,18 @@ describe('PostSlug', () => { }) }) }) + + describe('reply method called when emitted reply received', () => { + it('CommentList', async () => { + wrapper.find(CommentList).vm.$emit('reply', { + id: 'commentAuthorId', + slug: 'ogerly', + }) + expect(stubs.HcEditor.methods.insertReply).toHaveBeenCalledWith({ + id: 'commentAuthorId', + slug: 'ogerly', + }) + }) + }) }) }) diff --git a/webapp/pages/post/_id/_slug/index.vue b/webapp/pages/post/_id/_slug/index.vue index c8a2d910d..1d107941a 100644 --- a/webapp/pages/post/_id/_slug/index.vue +++ b/webapp/pages/post/_id/_slug/index.vue @@ -84,14 +84,16 @@ - @@ -114,7 +116,7 @@ import ContentMenu from '~/components/ContentMenu/ContentMenu' import UserTeaser from '~/components/UserTeaser/UserTeaser' import HcShoutButton from '~/components/ShoutButton.vue' import CommentForm from '~/components/CommentForm/CommentForm' -import HcCommentList from '~/components/CommentList/CommentList' +import CommentList from '~/components/CommentList/CommentList' import { postMenuModalsData, deletePostMutation } from '~/components/utils/PostHelpers' import PostQuery from '~/graphql/PostQuery' import HcEmotions from '~/components/Emotions/Emotions' @@ -133,7 +135,7 @@ export default { HcShoutButton, ContentMenu, CommentForm, - HcCommentList, + CommentList, HcEmotions, ContentViewer, }, @@ -170,6 +172,9 @@ export default { }, }, methods: { + reply(message) { + this.$refs.commentForm && this.$refs.commentForm.reply(message) + }, isAuthor(id) { return this.$store.getters['auth/user'].id === id },