Merge branch '260-add-comment-form' of https://github.com/Human-Connection/Human-Connection into 260-add-comment-form

This commit is contained in:
Wolfgang Huß 2019-04-24 14:09:41 +02:00
commit 3fa064986e
15 changed files with 328 additions and 60 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,45 @@
import { neo4jgraphql } from 'neo4j-graphql-js'
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}`, {
postId
})
session.close()
let comments = []
transactionRes.records.map(record => {
comments.push(record.get('comment'))
})
return comments
}
},
Mutation: {
CreateComment: async (object, params, context, resolveInfo) => {
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,61 @@
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 post', 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)
})
})
})

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,23 @@
import { When, Then } from 'cypress-cucumber-preprocessor/steps'
When('I should be able to post a comment', () => {
cy.get('.ProseMirror')
.type('This is a comment')
.get('.ds-form')
.submit()
.get('button')
.contains('Submit Comment')
.click()
.get('.iziToast-message')
.contains('Comment Submitted')
})
Then('I should see my comment', () => {
cy.get('div.comment p')
.should('contain', 'This is a comment')
})
Then('the editor should be cleared', () => {
cy.get('.ProseMirror p')
.should('have.class', 'is-empty')
})

View File

@ -0,0 +1,17 @@
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"
Then I should be able to post a comment
And I should see my comment
And the editor should be cleared

View File

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

View File

@ -111,7 +111,9 @@
},
"takeAction": {
"name": "Take action"
}
},
"submitComment": "Submit Comment",
"commentSubmitted": "Comment Submitted"
},
"quotes": {
"african": {

View File

@ -96,26 +96,72 @@
<ds-space margin="small" />
<!-- Comments -->
<ds-section slot="footer">
<h3 style="margin-top: 0;">
<span>
<ds-icon name="comments" />
<ds-tag
v-if="post.comments"
style="margin-top: -4px; margin-left: -12px; position: absolute;"
color="primary"
size="small"
round
>{{ post.commentsCount }}</ds-tag>&nbsp; Comments
</span>
</h3>
<ds-flex>
<ds-flex-item width="20%">
<h3 style="margin-top: 0;">
<span>
<ds-icon name="comments" />
<ds-tag
v-if="comments"
style="margin-top: -4px; margin-left: -12px; position: absolute;"
color="primary"
size="small"
round
>{{ post.commentsCount }}</ds-tag>&nbsp; Comments
</span>
</h3>
</ds-flex-item>
<ds-flex-item width="80%">
<ds-form
ref="commentForm"
v-model="form"
:schema="formSchema"
@submit="handleSubmit"
>
<template slot-scope="{ errors }">
<ds-card>
<no-ssr>
<hc-editor
:value="form.content"
@input="updateEditorContent"
/>
</no-ssr>
<ds-space />
<ds-flex>
<ds-flex-item width="50%" />
<ds-flex-item width="20%">
<ds-button
:disabled="loading || disabled"
ghost
@click.prevent="clearEditor"
>
{{ $t('actions.cancel') }}
</ds-button>
</ds-flex-item>
<ds-flex-item width="20%">
<ds-button
type="submit"
:loading="loading"
:disabled="disabled || errors"
primary
>
{{ $t('post.submitComment') }}
</ds-button>
</ds-flex-item>
</ds-flex>
</ds-card>
</template>
</ds-form>
</ds-flex-item>
</ds-flex>
<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"
/>
@ -139,6 +185,7 @@ import HcUser from '~/components/User'
import HcShoutButton from '~/components/ShoutButton.vue'
import HcEmpty from '~/components/Empty.vue'
import Comment from '~/components/Comment.vue'
import HcEditor from '~/components/Editor'
export default {
transition: {
@ -152,7 +199,8 @@ export default {
HcShoutButton,
HcEmpty,
Comment,
ContentMenu
ContentMenu,
HcEditor
},
head() {
return {
@ -162,14 +210,26 @@ export default {
data() {
return {
post: null,
comments: null,
ready: false,
title: 'loading'
title: 'loading',
loading: false,
disabled: false,
form: {
content: ''
},
formSchema: {
content: { required: true, min: 3 }
}
}
},
watch: {
Post(post) {
this.post = post[0] || {}
this.title = this.post.title
},
CommentByPost(comments) {
this.comments = comments || []
}
},
async asyncData(context) {
@ -278,6 +338,61 @@ export default {
methods: {
isAuthor(id) {
return this.$store.getters['auth/user'].id === id
},
updateEditorContent(value) {
this.$refs.commentForm.update('content', value)
},
clearEditor() {
this.loading = false
this.disabled = false
this.form.content = ' '
},
addComment(comment) {
this.$apollo.queries.CommentByPost.refetch()
// this.post = { ...this.post, comments: [...this.post.comments, comment] }
},
handleSubmit() {
const content = this.form.content
this.form.content = ' '
this.$apollo
.mutate({
mutation: gql`
mutation($postId: ID, $content: String!) {
CreateComment(postId: $postId, content: $content) {
id
content
}
}
`,
variables: {
postId: this.post.id,
content
}
})
.then(res => {
this.addComment(res.data.CreateComment)
this.loading = false
this.disabled = false
this.$toast.success(this.$t('post.commentSubmitted'))
})
.catch(err => {
this.$toast.error(err.message)
this.loading = false
this.disabled = false
})
}
},
apollo: {
CommentByPost: {
query() {
return require('~/graphql/CommentQuery.js').default(this)
},
variables() {
return {
postId: this.post.id
}
},
fetchPolicy: 'cache-and-network'
}
}
}