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 @@
+
+
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
},