Merge pull request #475 from Human-Connection/260-add-comment-form

Add Comment Form
This commit is contained in:
Robert Schäfer 2019-04-30 13:13:32 +02:00 committed by GitHub
commit 890810743a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 402 additions and 51 deletions

View File

@ -11,6 +11,7 @@ import shout from './resolvers/shout.js'
import rewards from './resolvers/rewards.js'
import socialMedia from './resolvers/socialMedia.js'
import notifications from './resolvers/notifications'
import comments from './resolvers/comments'
export const typeDefs = fs
.readFileSync(
@ -22,7 +23,8 @@ export const resolvers = {
Query: {
...statistics.Query,
...userManagement.Query,
...notifications.Query
...notifications.Query,
...comments.Query
},
Mutation: {
...userManagement.Mutation,
@ -33,6 +35,7 @@ export const resolvers = {
...shout.Mutation,
...rewards.Mutation,
...socialMedia.Mutation,
...notifications.Mutation
...notifications.Mutation,
...comments.Mutation
}
}

View File

@ -86,7 +86,8 @@ const permissions = shield({
unshout: isAuthenticated,
changePassword: isAuthenticated,
enable: isModerator,
disable: isModerator
disable: isModerator,
CreateComment: isAuthenticated
// CreateUser: allow,
},
User: {

View File

@ -23,21 +23,19 @@ beforeAll(async () => {
])
await Promise.all([
factory.create('Comment', { id: 'c2', content: 'Enabled comment on public post' })
factory.create('Comment', { id: 'c2', postId: 'p3', content: 'Enabled comment on public post' })
])
await Promise.all([
factory.relate('Comment', 'Author', { from: 'u1', to: 'c2' }),
factory.relate('Comment', 'Post', { from: 'c2', to: 'p3' })
factory.relate('Comment', 'Author', { from: 'u1', to: 'c2' })
])
const asTroll = Factory()
await asTroll.authenticateAs({ email: 'troll@example.org', password: '1234' })
await asTroll.create('Post', { id: 'p2', title: 'Disabled post', content: 'This is an offensive post content', image: '/some/offensive/image.jpg', deleted: false })
await asTroll.create('Comment', { id: 'c1', content: 'Disabled comment' })
await asTroll.create('Comment', { id: 'c1', postId: 'p3', content: 'Disabled comment' })
await Promise.all([
asTroll.relate('Comment', 'Author', { from: 'u2', to: 'c1' }),
asTroll.relate('Comment', 'Post', { from: 'c1', to: 'p3' })
asTroll.relate('Comment', 'Author', { from: 'u2', to: 'c1' })
])
const asModerator = Factory()

View File

@ -0,0 +1,52 @@
import { neo4jgraphql } from 'neo4j-graphql-js'
import { UserInputError } from 'apollo-server'
const COMMENT_MIN_LENGTH = 1
export default {
Query: {
CommentByPost: async (object, params, context, resolveInfo) => {
const { postId } = params
const session = context.driver.session()
const transactionRes = await session.run(`
MATCH (comment:Comment)-[:COMMENTS]->(post:Post {id: $postId})
RETURN comment {.id, .contentExcerpt, .createdAt} ORDER BY comment.createdAt ASC`, {
postId
})
session.close()
let comments = []
transactionRes.records.map(record => {
comments.push(record.get('comment'))
})
return comments
}
},
Mutation: {
CreateComment: async (object, params, context, resolveInfo) => {
const content = params.content.replace(/<(?:.|\n)*?>/gm, '').trim()
if (!params.content || content.length < COMMENT_MIN_LENGTH) {
throw new UserInputError(`Comment must be at least ${COMMENT_MIN_LENGTH} character long!`)
}
const { postId } = params
delete params.postId
const comment = await neo4jgraphql(object, params, context, resolveInfo, false)
const session = context.driver.session()
await session.run(`
MATCH (post:Post {id: $postId}), (comment:Comment {id: $commentId})
MERGE (post)<-[:COMMENTS]-(comment)
RETURN comment {.id, .content}`, {
postId,
commentId: comment.id
}
)
session.close()
return comment
}
}
}

View File

@ -0,0 +1,81 @@
import Factory from '../seed/factories'
import { GraphQLClient } from 'graphql-request'
import { host, login } from '../jest/helpers'
const factory = Factory()
let client
let variables
beforeEach(async () => {
await factory.create('User', {
email: 'test@example.org',
password: '1234'
})
})
afterEach(async () => {
await factory.cleanDatabase()
})
describe('CreateComment', () => {
const mutation = `
mutation($postId: ID, $content: String!) {
CreateComment(postId: $postId, content: $content) {
id
content
}
}
`
describe('unauthenticated', () => {
it('throws authorization error', async () => {
variables = {
postId: 'p1',
content: 'I\'m not authorised to comment'
}
client = new GraphQLClient(host)
await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised')
})
})
describe('authenticated', () => {
let headers
beforeEach(async () => {
headers = await login({ email: 'test@example.org', password: '1234' })
client = new GraphQLClient(host, { headers })
})
it('creates a comment', async () => {
variables = {
postId: 'p1',
content: 'I\'m authorised to comment'
}
const expected = {
CreateComment: {
content: 'I\'m authorised to comment'
}
}
await expect(client.request(mutation, variables)).resolves.toMatchObject(expected)
})
it('throw an error if an empty string is sent as content', async () => {
variables = {
postId: 'p1',
content: '<p></p>'
}
await expect(client.request(mutation, variables))
.rejects.toThrow('Comment must be at least 1 character long!')
})
it('throws an error if a comment does not contain a single character', async () => {
variables = {
postId: 'p1',
content: '<p> </p>'
}
await expect(client.request(mutation, variables))
.rejects.toThrow('Comment must be at least 1 character long!')
})
})
})

View File

@ -109,11 +109,11 @@ describe('disable', () => {
await factory.authenticateAs({ email: 'commenter@example.org', password: '1234' })
await Promise.all([
factory.create('Post', { id: 'p3' }),
factory.create('Comment', { id: 'c47' })
factory.create('Comment', { id: 'c47', postId: 'p3', content: 'this comment was created for this post' })
])
await Promise.all([
factory.relate('Comment', 'Author', { from: 'u45', to: 'c47' }),
factory.relate('Comment', 'Post', { from: 'c47', to: 'p3' })
factory.relate('Comment', 'Author', { from: 'u45', to: 'c47' })
])
}
})
@ -286,8 +286,7 @@ describe('enable', () => {
factory.create('Comment', { id: 'c456' })
])
await Promise.all([
factory.relate('Comment', 'Author', { from: 'u123', to: 'c456' }),
factory.relate('Comment', 'Post', { from: 'c456', to: 'p9' })
factory.relate('Comment', 'Author', { from: 'u123', to: 'c456' })
])
const disableMutation = `

View File

@ -3,7 +3,7 @@ import { neo4jgraphql } from 'neo4j-graphql-js'
export default {
Mutation: {
CreateSocialMedia: async (object, params, context, resolveInfo) => {
const socialMedia = await neo4jgraphql(object, params, context, resolveInfo, true)
const socialMedia = await neo4jgraphql(object, params, context, resolveInfo, false)
const session = context.driver.session()
await session.run(
`MATCH (owner:User {id: $userId}), (socialMedia:SocialMedia {id: $socialMediaId})

View File

@ -16,6 +16,7 @@ type Query {
LIMIT $limit
"""
)
CommentByPost(postId: ID!): [Comment]!
}
type Mutation {
# Get a JWT Token for the given Email and password
@ -210,6 +211,7 @@ type Post {
type Comment {
id: ID!
activityId: String
postId: ID
author: User @relation(name: "WROTE", direction: "IN")
content: String!
contentExcerpt: String

View File

@ -4,6 +4,7 @@ import uuid from 'uuid/v4'
export default function (params) {
const {
id = uuid(),
postId = 'p6',
content = [
faker.lorem.sentence(),
faker.lorem.sentence()
@ -12,12 +13,12 @@ export default function (params) {
return {
mutation: `
mutation($id: ID!, $content: String!) {
CreateComment(id: $id, content: $content) {
mutation($id: ID!, $postId: ID, $content: String!) {
CreateComment(id: $id, postId: $postId, content: $content) {
id
}
}
`,
variables: { id, content }
variables: { id, postId, content }
}
}

View File

@ -189,45 +189,33 @@ import Factory from './factories'
])
await Promise.all([
f.create('Comment', { id: 'c1' }),
f.create('Comment', { id: 'c2' }),
f.create('Comment', { id: 'c3' }),
f.create('Comment', { id: 'c4' }),
f.create('Comment', { id: 'c5' }),
f.create('Comment', { id: 'c6' }),
f.create('Comment', { id: 'c7' }),
f.create('Comment', { id: 'c8' }),
f.create('Comment', { id: 'c9' }),
f.create('Comment', { id: 'c10' }),
f.create('Comment', { id: 'c11' }),
f.create('Comment', { id: 'c12' })
f.create('Comment', { id: 'c1', postId: 'p1' }),
f.create('Comment', { id: 'c2', postId: 'p1' }),
f.create('Comment', { id: 'c3', postId: 'p3' }),
f.create('Comment', { id: 'c4', postId: 'p2' }),
f.create('Comment', { id: 'c5', postId: 'p3' }),
f.create('Comment', { id: 'c6', postId: 'p4' }),
f.create('Comment', { id: 'c7', postId: 'p2' }),
f.create('Comment', { id: 'c8', postId: 'p15' }),
f.create('Comment', { id: 'c9', postId: 'p15' }),
f.create('Comment', { id: 'c10', postId: 'p15' }),
f.create('Comment', { id: 'c11', postId: 'p15' }),
f.create('Comment', { id: 'c12', postId: 'p15' })
])
await Promise.all([
f.relate('Comment', 'Author', { from: 'u3', to: 'c1' }),
f.relate('Comment', 'Post', { from: 'c1', to: 'p1' }),
f.relate('Comment', 'Author', { from: 'u1', to: 'c2' }),
f.relate('Comment', 'Post', { from: 'c2', to: 'p1' }),
f.relate('Comment', 'Author', { from: 'u1', to: 'c3' }),
f.relate('Comment', 'Post', { from: 'c3', to: 'p3' }),
f.relate('Comment', 'Author', { from: 'u4', to: 'c4' }),
f.relate('Comment', 'Post', { from: 'c4', to: 'p2' }),
f.relate('Comment', 'Author', { from: 'u4', to: 'c5' }),
f.relate('Comment', 'Post', { from: 'c5', to: 'p3' }),
f.relate('Comment', 'Author', { from: 'u3', to: 'c6' }),
f.relate('Comment', 'Post', { from: 'c6', to: 'p4' }),
f.relate('Comment', 'Author', { from: 'u2', to: 'c7' }),
f.relate('Comment', 'Post', { from: 'c7', to: 'p2' }),
f.relate('Comment', 'Author', { from: 'u5', to: 'c8' }),
f.relate('Comment', 'Post', { from: 'c8', to: 'p15' }),
f.relate('Comment', 'Author', { from: 'u6', to: 'c9' }),
f.relate('Comment', 'Post', { from: 'c9', to: 'p15' }),
f.relate('Comment', 'Author', { from: 'u7', to: 'c10' }),
f.relate('Comment', 'Post', { from: 'c10', to: 'p15' }),
f.relate('Comment', 'Author', { from: 'u5', to: 'c11' }),
f.relate('Comment', 'Post', { from: 'c11', to: 'p15' }),
f.relate('Comment', 'Author', { from: 'u6', to: 'c12' }),
f.relate('Comment', 'Post', { from: 'c12', to: 'p15' })
f.relate('Comment', 'Author', { from: 'u6', to: 'c12' })
])
const disableMutation = 'mutation($id: ID!) { disable(id: $id) }'

View File

@ -0,0 +1,20 @@
import { When, Then } from 'cypress-cucumber-preprocessor/steps'
Then('I click on the {string} button', text => {
cy.get('button').contains(text).click()
})
Then('my comment should be successfully created', () => {
cy.get('.iziToast-message')
.contains('Comment Submitted')
})
Then('I should see my comment', () => {
cy.get('div.comment p')
.should('contain', 'Human Connection rocks')
})
Then('the editor should be cleared', () => {
cy.get('.ProseMirror p')
.should('have.class', 'is-empty')
})

View File

@ -0,0 +1,22 @@
Feature: Post Comment
As a user
I want to comment on contributions of others
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
And I am logged in
Scenario: Comment creation
Given I visit "post/bWBjpkTKZp/101-essays"
And I type in the following text:
"""
Human Connection rocks
"""
And I click on the "Comment" button
Then my comment should be successfully created
And I should see my comment
And the editor should be cleared

View File

@ -0,0 +1,119 @@
<template>
<ds-form
v-model="form"
@submit="handleSubmit"
>
<template slot-scope="{ errors }">
<ds-card>
<no-ssr>
<hc-editor
ref="editor"
:users="users"
:value="form.content"
@input="updateEditorContent"
/>
</no-ssr>
<ds-space />
<ds-flex :gutter="{ base: 'small', md: 'small', sm: 'x-large', xs: 'x-large' }">
<ds-flex-item :width="{ base: '0%', md: '50%', sm: '0%', xs: '0%' }" />
<ds-flex-item :width="{ base: '40%', md: '20%', sm: '30%', xs: '30%' }">
<ds-button
:disabled="disabled"
ghost
@click.prevent="clear"
>
{{ $t('actions.cancel') }}
</ds-button>
</ds-flex-item>
<ds-flex-item :width="{ base: '40%', md: '20%', sm: '40%', xs: '40%' }">
<ds-button
type="submit"
:disabled="disabled || errors"
primary
>
{{ $t('post.comment.submit') }}
</ds-button>
</ds-flex-item>
</ds-flex>
</ds-card>
</template>
</ds-form>
</template>
<script>
import gql from 'graphql-tag'
import HcEditor from '~/components/Editor'
export default {
components: {
HcEditor
},
props: {
post: { type: Object, default: () => {} },
comments: { type: Array, default: () => [] }
},
data() {
return {
disabled: true,
form: {
content: ''
},
users: []
}
},
methods: {
updateEditorContent(value) {
const content = value.replace(/<(?:.|\n)*?>/gm, '').trim()
if (content.length < 1) {
this.disabled = true
} else {
this.disabled = false
}
this.form.content = value
},
clear() {
this.$refs.editor.clear()
},
handleSubmit() {
this.$apollo
.mutate({
mutation: gql`
mutation($postId: ID, $content: String!) {
CreateComment(postId: $postId, content: $content) {
id
content
}
}
`,
variables: {
postId: this.post.id,
content: this.form.content
}
})
.then(res => {
this.$emit('addComment', res.data.CreateComment)
this.$refs.editor.clear()
this.$toast.success(this.$t('post.comment.submitted'))
})
.catch(err => {
this.$toast.error(err.message)
})
}
},
apollo: {
User: {
query() {
return gql(`{
User(orderBy: slug_asc) {
id
slug
}
}`)
},
result(result) {
this.users = result.data.User
}
}
}
}
</script>

View File

@ -154,7 +154,10 @@
</ds-button>
</div>
</editor-floating-menu>
<editor-content :editor="editor" />
<editor-content
ref="editor"
:editor="editor"
/>
</div>
</template>
@ -224,7 +227,7 @@ export default {
new ListItem(),
new Placeholder({
emptyNodeClass: 'is-empty',
emptyNodeText: 'Schreib etwas inspirerendes…'
emptyNodeText: this.$t('editor.placeholder')
}),
new History(),
new Mention({
@ -445,6 +448,9 @@ export default {
// remove link
command({ href: null })
}
},
clear() {
this.editor.clearContent(true)
}
}
}

View File

@ -9,14 +9,19 @@ localVue.use(Styleguide)
describe('Editor.vue', () => {
let wrapper
let propsData
let mocks
beforeEach(() => {
propsData = {}
mocks = {
$t: () => {}
}
})
describe('mount', () => {
let Wrapper = () => {
return (wrapper = mount(Editor, {
mocks,
propsData,
localVue,
sync: false,

View File

@ -0,0 +1,13 @@
import gql from 'graphql-tag'
export default app => {
return gql(`
query CommentByPost($postId: ID!) {
CommentByPost(postId: $postId) {
id
contentExcerpt
createdAt
}
}
`)
}

View File

@ -8,6 +8,9 @@
"moreInfo": "Was ist Human Connection?",
"hello": "Hallo"
},
"editor": {
"placeholder": "Schreib etwas Inspirierendes..."
},
"profile": {
"name": "Mein Profil",
"memberSince": "Mitglied seit",
@ -111,6 +114,10 @@
},
"takeAction": {
"name": "Aktiv werden"
},
"comment": {
"submit": "Kommentiere",
"submitted": "Kommentar Gesendet"
}
},
"quotes": {

View File

@ -8,6 +8,9 @@
"moreInfo": "What is Human Connection?",
"hello": "Hello"
},
"editor": {
"placeholder": "Leave your inspirational thoughts..."
},
"profile": {
"name": "My Profile",
"memberSince": "Member since",
@ -111,6 +114,10 @@
},
"takeAction": {
"name": "Take action"
},
"comment": {
"submit": "Comment",
"submitted": "Comment Submitted"
}
},
"quotes": {

View File

@ -96,26 +96,26 @@
<ds-space margin="small" />
<!-- Comments -->
<ds-section slot="footer">
<h3 style="margin-top: 0;">
<h3 style="margin-top: -10px;">
<span>
<ds-icon name="comments" />
<ds-tag
v-if="post.comments"
v-if="comments"
style="margin-top: -4px; margin-left: -12px; position: absolute;"
color="primary"
size="small"
round
>{{ post.commentsCount }}</ds-tag>&nbsp; Comments
>{{ comments.length }}</ds-tag>&nbsp; Comments
</span>
</h3>
<ds-space margin-bottom="large" />
<div
v-if="post.comments"
v-if="comments && comments.length"
id="comments"
class="comments"
>
<comment
v-for="comment in post.comments"
v-for="comment in comments"
:key="comment.id"
:comment="comment"
/>
@ -124,6 +124,11 @@
v-else
icon="messages"
/>
<ds-space margin-bottom="large" />
<hc-comment-form
:post="post"
@addComment="addComment"
/>
</ds-section>
</ds-card>
</transition>
@ -138,6 +143,7 @@ import ContentMenu from '~/components/ContentMenu'
import HcUser from '~/components/User'
import HcShoutButton from '~/components/ShoutButton.vue'
import HcEmpty from '~/components/Empty.vue'
import HcCommentForm from '~/components/CommentForm'
import Comment from '~/components/Comment.vue'
export default {
@ -152,7 +158,8 @@ export default {
HcShoutButton,
HcEmpty,
Comment,
ContentMenu
ContentMenu,
HcCommentForm
},
head() {
return {
@ -162,6 +169,7 @@ export default {
data() {
return {
post: null,
comments: null,
ready: false,
title: 'loading'
}
@ -170,6 +178,9 @@ export default {
Post(post) {
this.post = post[0] || {}
this.title = this.post.title
},
CommentByPost(comments) {
this.comments = comments || []
}
},
async asyncData(context) {
@ -278,6 +289,22 @@ export default {
methods: {
isAuthor(id) {
return this.$store.getters['auth/user'].id === id
},
addComment(comment) {
this.$apollo.queries.CommentByPost.refetch()
}
},
apollo: {
CommentByPost: {
query() {
return require('~/graphql/CommentQuery.js').default(this)
},
variables() {
return {
postId: this.post.id
}
},
fetchPolicy: 'cache-and-network'
}
}
}