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)
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()

View File

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

View File

@ -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')

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.
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

View File

@ -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'

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 Vuex from 'vuex'
const localVue = global.localVue
localVue.directive('scrollTo', jest.fn())
config.stubs['client-only'] = '<span><slot /></span>'
config.stubs['nuxt-link'] = '<span><slot /></span>'
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',
},
],
])
})
})
})
})
})

View File

@ -54,6 +54,15 @@
</button>
</div>
<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>
</div>
</template>
@ -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;

View File

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

View File

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

View File

@ -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

View File

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

View File

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

View File

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

View File

@ -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'] = '<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', () => {
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',
})
})
})
})
})

View File

@ -84,14 +84,16 @@
</ds-space>
<!-- Comments -->
<ds-section slot="footer">
<hc-comment-list
<comment-list
:post="post"
:routeHash="$route.hash"
@toggleNewCommentForm="toggleNewCommentForm"
@reply="reply"
/>
<ds-space margin-bottom="large" />
<comment-form
v-if="showNewCommentForm && !post.author.blocked"
ref="commentForm"
:post="post"
@createComment="createComment"
/>
@ -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
},