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

View File

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

View File

@ -23,21 +23,19 @@ beforeAll(async () => {
]) ])
await Promise.all([ 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([ await Promise.all([
factory.relate('Comment', 'Author', { from: 'u1', to: 'c2' }), factory.relate('Comment', 'Author', { from: 'u1', to: 'c2' })
factory.relate('Comment', 'Post', { from: 'c2', to: 'p3' })
]) ])
const asTroll = Factory() const asTroll = Factory()
await asTroll.authenticateAs({ email: 'troll@example.org', password: '1234' }) 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('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([ await Promise.all([
asTroll.relate('Comment', 'Author', { from: 'u2', to: 'c1' }), asTroll.relate('Comment', 'Author', { from: 'u2', to: 'c1' })
asTroll.relate('Comment', 'Post', { from: 'c1', to: 'p3' })
]) ])
const asModerator = Factory() 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 factory.authenticateAs({ email: 'commenter@example.org', password: '1234' })
await Promise.all([ await Promise.all([
factory.create('Post', { id: 'p3' }), 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([ await Promise.all([
factory.relate('Comment', 'Author', { from: 'u45', to: 'c47' }), factory.relate('Comment', 'Author', { from: 'u45', to: 'c47' })
factory.relate('Comment', 'Post', { from: 'c47', to: 'p3' })
]) ])
} }
}) })
@ -286,8 +286,7 @@ describe('enable', () => {
factory.create('Comment', { id: 'c456' }) factory.create('Comment', { id: 'c456' })
]) ])
await Promise.all([ await Promise.all([
factory.relate('Comment', 'Author', { from: 'u123', to: 'c456' }), factory.relate('Comment', 'Author', { from: 'u123', to: 'c456' })
factory.relate('Comment', 'Post', { from: 'c456', to: 'p9' })
]) ])
const disableMutation = ` const disableMutation = `

View File

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

View File

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

View File

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

View File

@ -189,45 +189,33 @@ import Factory from './factories'
]) ])
await Promise.all([ await Promise.all([
f.create('Comment', { id: 'c1' }), f.create('Comment', { id: 'c1', postId: 'p1' }),
f.create('Comment', { id: 'c2' }), f.create('Comment', { id: 'c2', postId: 'p1' }),
f.create('Comment', { id: 'c3' }), f.create('Comment', { id: 'c3', postId: 'p3' }),
f.create('Comment', { id: 'c4' }), f.create('Comment', { id: 'c4', postId: 'p2' }),
f.create('Comment', { id: 'c5' }), f.create('Comment', { id: 'c5', postId: 'p3' }),
f.create('Comment', { id: 'c6' }), f.create('Comment', { id: 'c6', postId: 'p4' }),
f.create('Comment', { id: 'c7' }), f.create('Comment', { id: 'c7', postId: 'p2' }),
f.create('Comment', { id: 'c8' }), f.create('Comment', { id: 'c8', postId: 'p15' }),
f.create('Comment', { id: 'c9' }), f.create('Comment', { id: 'c9', postId: 'p15' }),
f.create('Comment', { id: 'c10' }), f.create('Comment', { id: 'c10', postId: 'p15' }),
f.create('Comment', { id: 'c11' }), f.create('Comment', { id: 'c11', postId: 'p15' }),
f.create('Comment', { id: 'c12' }) f.create('Comment', { id: 'c12', postId: 'p15' })
]) ])
await Promise.all([ await Promise.all([
f.relate('Comment', 'Author', { from: 'u3', to: 'c1' }), 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', 'Author', { from: 'u1', to: 'c2' }),
f.relate('Comment', 'Post', { from: 'c2', to: 'p1' }),
f.relate('Comment', 'Author', { from: 'u1', to: 'c3' }), 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', 'Author', { from: 'u4', to: 'c4' }),
f.relate('Comment', 'Post', { from: 'c4', to: 'p2' }),
f.relate('Comment', 'Author', { from: 'u4', to: 'c5' }), 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', 'Author', { from: 'u3', to: 'c6' }),
f.relate('Comment', 'Post', { from: 'c6', to: 'p4' }),
f.relate('Comment', 'Author', { from: 'u2', to: 'c7' }), 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', 'Author', { from: 'u5', to: 'c8' }),
f.relate('Comment', 'Post', { from: 'c8', to: 'p15' }),
f.relate('Comment', 'Author', { from: 'u6', to: 'c9' }), 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', 'Author', { from: 'u7', to: 'c10' }),
f.relate('Comment', 'Post', { from: 'c10', to: 'p15' }),
f.relate('Comment', 'Author', { from: 'u5', to: 'c11' }), 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', 'Author', { from: 'u6', to: 'c12' }),
f.relate('Comment', 'Post', { from: 'c12', to: 'p15' })
]) ])
const disableMutation = 'mutation($id: ID!) { disable(id: $id) }' 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> </ds-button>
</div> </div>
</editor-floating-menu> </editor-floating-menu>
<editor-content :editor="editor" /> <editor-content
ref="editor"
:editor="editor"
/>
</div> </div>
</template> </template>
@ -224,7 +227,7 @@ export default {
new ListItem(), new ListItem(),
new Placeholder({ new Placeholder({
emptyNodeClass: 'is-empty', emptyNodeClass: 'is-empty',
emptyNodeText: 'Schreib etwas inspirerendes…' emptyNodeText: this.$t('editor.placeholder')
}), }),
new History(), new History(),
new Mention({ new Mention({
@ -445,6 +448,9 @@ export default {
// remove link // remove link
command({ href: null }) command({ href: null })
} }
},
clear() {
this.editor.clearContent(true)
} }
} }
} }

View File

@ -9,14 +9,19 @@ localVue.use(Styleguide)
describe('Editor.vue', () => { describe('Editor.vue', () => {
let wrapper let wrapper
let propsData let propsData
let mocks
beforeEach(() => { beforeEach(() => {
propsData = {} propsData = {}
mocks = {
$t: () => {}
}
}) })
describe('mount', () => { describe('mount', () => {
let Wrapper = () => { let Wrapper = () => {
return (wrapper = mount(Editor, { return (wrapper = mount(Editor, {
mocks,
propsData, propsData,
localVue, localVue,
sync: false, 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?", "moreInfo": "Was ist Human Connection?",
"hello": "Hallo" "hello": "Hallo"
}, },
"editor": {
"placeholder": "Schreib etwas Inspirierendes..."
},
"profile": { "profile": {
"name": "Mein Profil", "name": "Mein Profil",
"memberSince": "Mitglied seit", "memberSince": "Mitglied seit",
@ -111,6 +114,10 @@
}, },
"takeAction": { "takeAction": {
"name": "Aktiv werden" "name": "Aktiv werden"
},
"comment": {
"submit": "Kommentiere",
"submitted": "Kommentar Gesendet"
} }
}, },
"quotes": { "quotes": {

View File

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

View File

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