Merge pull request #2608 from Human-Connection/1680-Direct_answer_on_Comment

feat: 🍰 Direct Reply On Comment
This commit is contained in:
mattwr18 2020-01-31 16:30:29 +01:00 committed by GitHub
commit 4170e62f7a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 213 additions and 54 deletions

View File

@ -55,7 +55,19 @@ export default function create() {
if (authorId) author = await neodeInstance.find('User', authorId) if (authorId) author = await neodeInstance.find('User', authorId)
author = author || (await factoryInstance.create('User')) author = author || (await factoryInstance.create('User'))
const post = await neodeInstance.create('Post', args) 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') await post.relateTo(author, 'author')
if (comment) await post.relateTo(comment, 'comments')
if (args.pinned) { if (args.pinned) {
args.pinnedAt = args.pinnedAt || new Date().toISOString() args.pinnedAt = args.pinnedAt || new Date().toISOString()

View File

@ -41,6 +41,16 @@ export default {
language: { type: 'string', allow: [null] }, language: { type: 'string', allow: [null] },
imageBlurred: { type: 'boolean', default: false }, imageBlurred: { type: 'boolean', default: false },
imageAspectRatio: { type: 'float', default: 1.0 }, 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] }, pinned: { type: 'boolean', default: null, valid: [null, true] },
pinnedAt: { type: 'string', isoDate: true }, pinnedAt: { type: 'string', isoDate: true },
} }

View File

@ -17,8 +17,13 @@ Then("I click on the {string} button", text => {
.click(); .click();
}); });
Then("I click on the reply button", () => {
cy.get(".reply-button")
.click();
});
Then("my comment should be successfully created", () => { 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", () => { 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"); 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)=> { When("I open the content menu of post {string}", (title)=> {
cy.contains('.post-card', title) cy.contains('.post-card', title)
.find('.content-menu .base-button') .find('.content-menu .base-button')

View File

@ -4,10 +4,10 @@ Feature: Post Comment
To be able to express my thoughts and emotions about these, discuss, and add give further information. To be able to express my thoughts and emotions about these, discuss, and add give further information.
Background: Background:
Given we have the following posts in our database: Given I have a user account
| id | title | slug | And we have the following posts in our database:
| bWBjpkTKZp | 101 Essays that will change the way you think | 101-essays | | id | title | slug | authorId | commentContent |
And I have a user account | 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 And I am logged in
Scenario: Comment creation Scenario: Comment creation
@ -36,3 +36,8 @@ Feature: Post Comment
Then my comment should be successfully created Then my comment should be successfully created
And I should see an abreviated version of my comment And I should see an abreviated version of my comment
And the editor should be cleared 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

View File

@ -15,7 +15,6 @@
/* globals Cypress cy */ /* globals Cypress cy */
import "cypress-file-upload"; import "cypress-file-upload";
import helpers from "./helpers"; import helpers from "./helpers";
import users from "../fixtures/users.json";
import { GraphQLClient, request } from 'graphql-request' import { GraphQLClient, request } from 'graphql-request'
import { gql } from '../../backend/src/helpers/jest' import { gql } from '../../backend/src/helpers/jest'
import config from '../../backend/src/config' import config from '../../backend/src/config'

View File

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<title>level-down</title>
<path d="M5 5h17v19.063l4.281-4.281 1.438 1.438-6 6-0.719 0.688-0.719-0.688-6-6 1.438-1.438 4.281 4.281v-17.063h-15v-2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 293 B

View File

@ -1,17 +1,15 @@
import { config, shallowMount } from '@vue/test-utils' import { config, mount } from '@vue/test-utils'
import Comment from './Comment.vue' import Comment from './Comment.vue'
import Vuex from 'vuex' import Vuex from 'vuex'
const localVue = global.localVue const localVue = global.localVue
localVue.directive('scrollTo', jest.fn())
config.stubs['client-only'] = '<span><slot /></span>' config.stubs['client-only'] = '<span><slot /></span>'
config.stubs['nuxt-link'] = '<span><slot /></span>'
describe('Comment.vue', () => { describe('Comment.vue', () => {
let propsData let propsData, mocks, stubs, getters, wrapper, Wrapper
let mocks
let getters
let wrapper
let Wrapper
beforeEach(() => { beforeEach(() => {
propsData = {} propsData = {}
@ -39,6 +37,9 @@ describe('Comment.vue', () => {
}), }),
}, },
} }
stubs = {
ContentViewer: true,
}
getters = { getters = {
'auth/user': () => { 'auth/user': () => {
return {} return {}
@ -47,18 +48,19 @@ describe('Comment.vue', () => {
} }
}) })
describe('shallowMount', () => { describe('mount', () => {
beforeEach(jest.useFakeTimers) beforeEach(jest.useFakeTimers)
Wrapper = () => { Wrapper = () => {
const store = new Vuex.Store({ const store = new Vuex.Store({
getters, getters,
}) })
return shallowMount(Comment, { return mount(Comment, {
store, store,
propsData, propsData,
mocks, mocks,
localVue, localVue,
stubs,
}) })
} }
@ -68,6 +70,7 @@ describe('Comment.vue', () => {
id: '2', id: '2',
contentExcerpt: 'Hello I am a comment content', contentExcerpt: 'Hello I am a comment content',
content: 'Hello I am 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',
},
],
])
})
})
}) })
}) })
}) })

View File

@ -54,6 +54,15 @@
</button> </button>
</div> </div>
<ds-space margin-bottom="small" /> <ds-space margin-bottom="small" />
<base-button
:title="this.$t('post.comment.reply')"
icon="level-down"
class="reply-button"
circle
size="small"
v-scroll-to="'.editor'"
@click.prevent="reply"
></base-button>
</ds-card> </ds-card>
</div> </div>
</template> </template>
@ -67,6 +76,7 @@ import ContentViewer from '~/components/Editor/ContentViewer'
import CommentForm from '~/components/CommentForm/CommentForm' import CommentForm from '~/components/CommentForm/CommentForm'
import CommentMutations from '~/graphql/CommentMutations' import CommentMutations from '~/graphql/CommentMutations'
import scrollToAnchor from '~/mixins/scrollToAnchor.js' import scrollToAnchor from '~/mixins/scrollToAnchor.js'
import BaseButton from '~/components/_new/generic/BaseButton/BaseButton'
export default { export default {
mixins: [scrollToAnchor], mixins: [scrollToAnchor],
@ -86,6 +96,7 @@ export default {
ContentMenu, ContentMenu,
ContentViewer, ContentViewer,
CommentForm, CommentForm,
BaseButton,
}, },
props: { props: {
routeHash: { type: String, default: () => '' }, routeHash: { type: String, default: () => '' },
@ -105,7 +116,6 @@ export default {
if (this.isLongComment && this.isCollapsed) { if (this.isLongComment && this.isCollapsed) {
return this.$filters.truncate(this.comment.content, COMMENT_TRUNCATE_TO_LENGTH) return this.$filters.truncate(this.comment.content, COMMENT_TRUNCATE_TO_LENGTH)
} }
return this.comment.content return this.comment.content
}, },
displaysComment() { displaysComment() {
@ -141,6 +151,10 @@ export default {
}, },
}, },
methods: { methods: {
reply() {
const message = { slug: this.comment.author.slug, id: this.comment.author.id }
this.$emit('reply', message)
},
checkAnchor(anchor) { checkAnchor(anchor) {
return `#${this.anchor}` === anchor return `#${this.anchor}` === anchor
}, },
@ -193,6 +207,14 @@ export default {
float: right; float: right;
} }
.reply-button {
float: right;
top: 0px;
}
.reply-button:after {
clear: both;
}
@keyframes highlight { @keyframes highlight {
0% { 0% {
border: 1px solid $color-primary; border: 1px solid $color-primary;

View File

@ -51,6 +51,9 @@ export default {
} }
}, },
methods: { methods: {
reply(message) {
this.$refs.editor.insertReply(message)
},
updateEditorContent(value) { updateEditorContent(value) {
const sanitizedContent = this.$filters.removeHtml(value, false) const sanitizedContent = this.$filters.removeHtml(value, false)
if (!this.update) { if (!this.update) {
@ -133,8 +136,8 @@ export default {
query() { query() {
return minimisedUserQuery() return minimisedUserQuery()
}, },
result(result) { update({ User }) {
this.users = result.data.User this.users = User
}, },
}, },
}, },

View File

@ -1,10 +1,13 @@
import { config, mount } from '@vue/test-utils' import { config, mount } from '@vue/test-utils'
import CommentList from './CommentList' import CommentList from './CommentList'
import Comment from '~/components/Comment/Comment'
import Vuex from 'vuex' import Vuex from 'vuex'
import Vue from 'vue'
const localVue = global.localVue const localVue = global.localVue
localVue.filter('truncate', string => string) localVue.filter('truncate', string => string)
localVue.directive('scrollTo', jest.fn())
config.stubs['v-popover'] = '<span><slot /></span>' config.stubs['v-popover'] = '<span><slot /></span>'
config.stubs['nuxt-link'] = '<span><slot /></span>' config.stubs['nuxt-link'] = '<span><slot /></span>'
@ -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',
},
],
])
})
})
}) })
}) })

View File

@ -12,9 +12,11 @@
:comment="comment" :comment="comment"
:post="post" :post="post"
:routeHash="routeHash" :routeHash="routeHash"
class="comment-tag"
@deleteComment="updateCommentList" @deleteComment="updateCommentList"
@updateComment="updateCommentList" @updateComment="updateCommentList"
@toggleNewCommentForm="toggleNewCommentForm" @toggleNewCommentForm="toggleNewCommentForm"
@reply="reply"
/> />
</div> </div>
</div> </div>
@ -35,6 +37,9 @@ export default {
post: { type: Object, default: () => {} }, post: { type: Object, default: () => {} },
}, },
methods: { methods: {
reply(message) {
this.$emit('reply', message)
},
checkAnchor(anchor) { checkAnchor(anchor) {
return anchor === '#comments' return anchor === '#comments'
}, },

View File

@ -141,7 +141,6 @@ export default {
methods: { methods: {
openSuggestionList({ items, query, range, command, virtualNode }, suggestionType) { openSuggestionList({ items, query, range, command, virtualNode }, suggestionType) {
this.suggestionType = suggestionType this.suggestionType = suggestionType
this.query = this.sanitizeQuery(query) this.query = this.sanitizeQuery(query)
this.filteredItems = items this.filteredItems = items
this.suggestionRange = range this.suggestionRange = range
@ -237,6 +236,9 @@ export default {
const content = e.getHTML() const content = e.getHTML()
this.$emit('input', content) this.$emit('input', content)
}, },
insertReply(message) {
this.editor.commands.mention({ id: message.id, label: message.slug })
},
toggleLinkInput(attrs, element) { toggleLinkInput(attrs, element) {
if (!this.isLinkInputActive && attrs && element) { if (!this.isLinkInputActive && attrs && element) {
this.$refs.linkInput.linkUrl = attrs.href this.$refs.linkInput.linkUrl = attrs.href

View File

@ -105,7 +105,7 @@ export default {
postId: this.post.id, postId: this.post.id,
} }
}, },
result({ data: { PostsEmotionsByCurrentUser } }) { update({ PostsEmotionsByCurrentUser }) {
this.selectedEmotions = PostsEmotionsByCurrentUser this.selectedEmotions = PostsEmotionsByCurrentUser
}, },
}, },

View File

@ -279,8 +279,9 @@
}, },
"comment": { "comment": {
"submit": "Kommentiere", "submit": "Kommentiere",
"submitted": "Kommentar gesendet", "submitted": "Kommentar gesendet!",
"updated": "Änderungen gespeichert" "updated": "Änderungen gespeichert",
"reply": "Antworten"
}, },
"edited": "bearbeitet" "edited": "bearbeitet"
}, },

View File

@ -444,8 +444,9 @@
}, },
"comment": { "comment": {
"submit": "Comment", "submit": "Comment",
"submitted": "Comment Submitted", "submitted": "Comment submitted!",
"updated": "Changes Saved" "updated": "Changes saved!",
"reply": "Reply"
}, },
"edited": "edited" "edited": "edited"
}, },

View File

@ -1,29 +1,33 @@
import { config, shallowMount } from '@vue/test-utils' import { config, mount } from '@vue/test-utils'
import PostSlug from './index.vue'
import Vuex from 'vuex' import Vuex from 'vuex'
import PostSlug from './index.vue'
const localVue = global.localVue import CommentList from '~/components/CommentList/CommentList'
config.stubs['client-only'] = '<span><slot /></span>' config.stubs['client-only'] = '<span><slot /></span>'
config.stubs['nuxt-link'] = '<span><slot /></span>'
config.stubs['router-link'] = '<span><slot /></span>'
const localVue = global.localVue
localVue.directive('scrollTo', jest.fn())
describe('PostSlug', () => { describe('PostSlug', () => {
let wrapper let store, propsData, mocks, stubs, wrapper, Wrapper
let Wrapper
let store
let mocks
beforeEach(() => { beforeEach(() => {
store = new Vuex.Store({ store = new Vuex.Store({
getters: { getters: {
'auth/user': () => { 'auth/user': () => {
return {} return { id: '1stUser' }
}, },
'auth/isModerator': () => false,
}, },
}) })
propsData = {}
mocks = { mocks = {
$t: jest.fn(), $t: jest.fn(),
$filters: { $filters: {
truncate: a => a, truncate: a => a,
removeHtml: a => a,
}, },
$route: { $route: {
hash: '', hash: '',
@ -40,38 +44,53 @@ describe('PostSlug', () => {
}, },
$apollo: { $apollo: {
mutate: jest.fn().mockResolvedValue(), mutate: jest.fn().mockResolvedValue(),
query: jest.fn().mockResolvedValue({ data: { PostEmotionsCountByEmotion: {} } }),
}, },
$scrollTo: jest.fn(),
} }
}) stubs = {
HcEditor: { render: () => {}, methods: { insertReply: jest.fn(() => null) } },
describe('shallowMount', () => { ContentViewer: true,
Wrapper = () => {
return shallowMount(PostSlug, {
store,
mocks,
localVue,
})
} }
jest.useFakeTimers()
beforeEach(jest.useFakeTimers)
describe('test Post callbacks', () => {
beforeEach(() => {
wrapper = Wrapper() wrapper = Wrapper()
wrapper.setData({ wrapper.setData({
post: { post: {
id: 'p23', id: '1',
name: 'It is a post',
author: { author: {
id: 'u1', id: '1stUser',
},
comments: [
{
id: 'comment134',
contentExcerpt: 'this is a comment',
content: 'this is a comment',
author: {
id: '1stUser',
slug: '1st-user',
}, },
}, },
],
},
ready: true,
}) })
}) })
describe('mount', () => {
Wrapper = () => {
return mount(PostSlug, {
store,
mocks,
localVue,
propsData,
stubs,
})
}
describe('test Post callbacks', () => {
describe('deletion of Post from Page by invoking "deletePostCallback()"', () => { describe('deletion of Post from Page by invoking "deletePostCallback()"', () => {
beforeEach(() => { beforeEach(async () => {
wrapper.vm.deletePostCallback() await wrapper.vm.deletePostCallback()
}) })
describe('after timeout', () => { 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',
})
})
})
}) })
}) })

View File

@ -84,14 +84,16 @@
</ds-space> </ds-space>
<!-- Comments --> <!-- Comments -->
<ds-section slot="footer"> <ds-section slot="footer">
<hc-comment-list <comment-list
:post="post" :post="post"
:routeHash="$route.hash" :routeHash="$route.hash"
@toggleNewCommentForm="toggleNewCommentForm" @toggleNewCommentForm="toggleNewCommentForm"
@reply="reply"
/> />
<ds-space margin-bottom="large" /> <ds-space margin-bottom="large" />
<comment-form <comment-form
v-if="showNewCommentForm && !post.author.blocked" v-if="showNewCommentForm && !post.author.blocked"
ref="commentForm"
:post="post" :post="post"
@createComment="createComment" @createComment="createComment"
/> />
@ -114,7 +116,7 @@ import ContentMenu from '~/components/ContentMenu/ContentMenu'
import UserTeaser from '~/components/UserTeaser/UserTeaser' import UserTeaser from '~/components/UserTeaser/UserTeaser'
import HcShoutButton from '~/components/ShoutButton.vue' import HcShoutButton from '~/components/ShoutButton.vue'
import CommentForm from '~/components/CommentForm/CommentForm' 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 { postMenuModalsData, deletePostMutation } from '~/components/utils/PostHelpers'
import PostQuery from '~/graphql/PostQuery' import PostQuery from '~/graphql/PostQuery'
import HcEmotions from '~/components/Emotions/Emotions' import HcEmotions from '~/components/Emotions/Emotions'
@ -133,7 +135,7 @@ export default {
HcShoutButton, HcShoutButton,
ContentMenu, ContentMenu,
CommentForm, CommentForm,
HcCommentList, CommentList,
HcEmotions, HcEmotions,
ContentViewer, ContentViewer,
}, },
@ -170,6 +172,9 @@ export default {
}, },
}, },
methods: { methods: {
reply(message) {
this.$refs.commentForm && this.$refs.commentForm.reply(message)
},
isAuthor(id) { isAuthor(id) {
return this.$store.getters['auth/user'].id === id return this.$store.getters['auth/user'].id === id
}, },