Merge pull request #788 from Human-Connection/552-update_comment

Update comment
This commit is contained in:
Wolfgang Huß 2019-07-17 10:36:53 +02:00 committed by GitHub
commit e0ab41a3d1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 388 additions and 127 deletions

View File

@ -176,6 +176,7 @@ const permissions = shield(
enable: isModerator,
disable: isModerator,
CreateComment: isAuthenticated,
UpdateComment: isAuthor,
DeleteComment: isAuthor,
DeleteUser: isDeletingOwnAccount,
requestPasswordReset: allow,

View File

@ -1,18 +0,0 @@
import { UserInputError } from 'apollo-server'
const validateUrl = async (resolve, root, args, context, info) => {
const { url } = args
const isValid = url.match(/^(?:https?:\/\/)(?:[^@\n])?(?:www\.)?([^:/\n?]+)/g)
if (isValid) {
/* eslint-disable-next-line no-return-await */
return await resolve(root, args, context, info)
} else {
throw new UserInputError('Input is not a URL')
}
}
export default {
Mutation: {
CreateSocialMedia: validateUrl,
},
}

View File

@ -45,9 +45,20 @@ const validateCommentCreation = async (resolve, root, args, context, info) => {
}
}
const validateUpdateComment = async (resolve, root, args, context, info) => {
const COMMENT_MIN_LENGTH = 1
const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim()
if (!args.content || content.length < COMMENT_MIN_LENGTH) {
throw new UserInputError(`Comment must be at least ${COMMENT_MIN_LENGTH} character long!`)
}
return resolve(root, args, context, info)
}
export default {
Mutation: {
CreateSocialMedia: validate(socialMediaSchema),
CreateComment: validateCommentCreation,
UpdateComment: validateUpdateComment,
},
}

View File

@ -9,7 +9,28 @@ let createPostVariables
let createCommentVariablesSansPostId
let createCommentVariablesWithNonExistentPost
let userParams
let authorParams
let headers
const createPostMutation = gql`
mutation($id: ID!, $title: String!, $content: String!) {
CreatePost(id: $id, title: $title, content: $content) {
id
}
}
`
const createCommentMutation = gql`
mutation($id: ID, $postId: ID!, $content: String!) {
CreateComment(id: $id, postId: $postId, content: $content) {
id
content
}
}
`
createPostVariables = {
id: 'p1',
title: 'post to comment on',
content: 'please comment on me',
}
beforeEach(async () => {
userParams = {
@ -25,21 +46,6 @@ afterEach(async () => {
})
describe('CreateComment', () => {
const createCommentMutation = gql`
mutation($postId: ID!, $content: String!) {
CreateComment(postId: $postId, content: $content) {
id
content
}
}
`
const createPostMutation = gql`
mutation($id: ID!, $title: String!, $content: String!) {
CreatePost(id: $id, title: $title, content: $content) {
id
}
}
`
describe('unauthenticated', () => {
it('throws authorization error', async () => {
createCommentVariables = {
@ -54,7 +60,6 @@ describe('CreateComment', () => {
})
describe('authenticated', () => {
let headers
beforeEach(async () => {
headers = await login(userParams)
client = new GraphQLClient(host, {
@ -64,11 +69,6 @@ describe('CreateComment', () => {
postId: 'p1',
content: "I'm authorised to comment",
}
createPostVariables = {
id: 'p1',
title: 'post to comment on',
content: 'please comment on me',
}
await client.request(createPostMutation, createPostVariables)
})
@ -187,19 +187,8 @@ describe('CreateComment', () => {
})
})
describe('DeleteComment', () => {
const deleteCommentMutation = gql`
mutation($id: ID!) {
DeleteComment(id: $id) {
id
}
}
`
let deleteCommentVariables = {
id: 'c1',
}
describe('ManageComments', () => {
let authorParams
beforeEach(async () => {
authorParams = {
email: 'author@example.org',
@ -213,51 +202,178 @@ describe('DeleteComment', () => {
content: 'Post to be commented',
})
await asAuthor.create('Comment', {
id: 'c1',
id: 'c456',
postId: 'p1',
content: 'Comment to be deleted',
})
})
describe('unauthenticated', () => {
it('throws authorization error', async () => {
client = new GraphQLClient(host)
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).rejects.toThrow(
'Not Authorised',
)
})
})
describe('authenticated but not the author', () => {
beforeEach(async () => {
let headers
headers = await login(userParams)
client = new GraphQLClient(host, { headers })
})
it('throws authorization error', async () => {
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).rejects.toThrow(
'Not Authorised',
)
})
})
describe('authenticated as author', () => {
beforeEach(async () => {
let headers
headers = await login(authorParams)
client = new GraphQLClient(host, { headers })
})
it('deletes the comment', async () => {
const expected = {
DeleteComment: {
id: 'c1',
},
describe('UpdateComment', () => {
const updateCommentMutation = gql`
mutation($content: String!, $id: ID!) {
UpdateComment(content: $content, id: $id) {
id
content
}
}
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).resolves.toEqual(
expected,
)
`
let updateCommentVariables = {
id: 'c456',
content: 'The comment is updated',
}
describe('unauthenticated', () => {
it('throws authorization error', async () => {
client = new GraphQLClient(host)
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
'Not Authorised',
)
})
})
describe('authenticated but not the author', () => {
beforeEach(async () => {
headers = await login({
email: 'test@example.org',
password: '1234',
})
client = new GraphQLClient(host, {
headers,
})
})
it('throws authorization error', async () => {
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
'Not Authorised',
)
})
})
describe('authenticated as author', () => {
beforeEach(async () => {
headers = await login(authorParams)
client = new GraphQLClient(host, {
headers,
})
})
it('updates the comment', async () => {
const expected = {
UpdateComment: {
id: 'c456',
content: 'The comment is updated',
},
}
await expect(
client.request(updateCommentMutation, updateCommentVariables),
).resolves.toEqual(expected)
})
it('throw an error if an empty string is sent from the editor as content', async () => {
updateCommentVariables = {
id: 'c456',
content: '<p></p>',
}
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
'Comment must be at least 1 character long!',
)
})
it('throws an error if a comment sent from the editor does not contain a single letter character', async () => {
updateCommentVariables = {
id: 'c456',
content: '<p> </p>',
}
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
'Comment must be at least 1 character long!',
)
})
it('throws an error if commentId is sent as an empty string', async () => {
updateCommentVariables = {
id: '',
content: '<p>Hello</p>',
}
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
'Not Authorised!',
)
})
it('throws an error if the comment does not exist in the database', async () => {
updateCommentVariables = {
id: 'c1000',
content: '<p>Hello</p>',
}
await expect(client.request(updateCommentMutation, updateCommentVariables)).rejects.toThrow(
'Not Authorised!',
)
})
})
})
describe('DeleteComment', () => {
const deleteCommentMutation = gql`
mutation($id: ID!) {
DeleteComment(id: $id) {
id
}
}
`
let deleteCommentVariables = {
id: 'c456',
}
describe('unauthenticated', () => {
it('throws authorization error', async () => {
client = new GraphQLClient(host)
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).rejects.toThrow(
'Not Authorised',
)
})
})
describe('authenticated but not the author', () => {
beforeEach(async () => {
headers = await login({
email: 'test@example.org',
password: '1234',
})
client = new GraphQLClient(host, {
headers,
})
})
it('throws authorization error', async () => {
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).rejects.toThrow(
'Not Authorised',
)
})
})
describe('authenticated as author', () => {
beforeEach(async () => {
headers = await login(authorParams)
client = new GraphQLClient(host, {
headers,
})
})
it('deletes the comment', async () => {
const expected = {
DeleteComment: {
id: 'c456',
},
}
await expect(
client.request(deleteCommentMutation, deleteCommentVariables),
).resolves.toEqual(expected)
})
})
})
})

View File

@ -24,7 +24,7 @@ type Mutation {
): Comment
UpdateComment(
id: ID!
content: String
content: String!
contentExcerpt: String
deleted: Boolean
disabled: Boolean

View File

@ -23,49 +23,61 @@
:modalsData="menuModalsData"
style="float-right"
:is-owner="isAuthor(author.id)"
@showEditCommentMenu="editCommentMenu"
/>
</no-ssr>
<!-- eslint-disable vue/no-v-html -->
<!-- TODO: replace editor content with tiptap render view -->
<div v-if="isCollapsed" v-html="comment.contentExcerpt" style="padding-left: 40px;" />
<div
v-show="comment.content !== comment.contentExcerpt"
style="text-align: right; margin-right: 20px; margin-top: -12px;"
>
<a v-if="isCollapsed" style="padding-left: 40px;" @click="isCollapsed = !isCollapsed">
{{ $t('comment.show.more') }}
</a>
<ds-space margin-bottom="small" />
<div v-if="openEditCommentMenu">
<hc-edit-comment-form
:comment="comment"
:post="post"
@showEditCommentMenu="editCommentMenu"
/>
</div>
<div v-if="!isCollapsed" v-html="comment.content" style="padding-left: 40px;" />
<div style="text-align: right; margin-right: 20px; margin-top: -12px;">
<a v-if="!isCollapsed" @click="isCollapsed = !isCollapsed" style="padding-left: 40px; ">
{{ $t('comment.show.less') }}
</a>
<div v-show="!openEditCommentMenu">
<div v-if="isCollapsed" v-html="comment.contentExcerpt" style="padding-left: 40px;" />
<div
v-show="comment.content !== comment.contentExcerpt"
style="text-align: right; margin-right: 20px; margin-top: -12px;"
>
<a v-if="isCollapsed" style="padding-left: 40px;" @click="isCollapsed = !isCollapsed">
{{ $t('comment.show.more') }}
</a>
</div>
<div v-if="!isCollapsed" v-html="comment.content" style="padding-left: 40px;" />
<div style="text-align: right; margin-right: 20px; margin-top: -12px;">
<a v-if="!isCollapsed" @click="isCollapsed = !isCollapsed" style="padding-left: 40px; ">
{{ $t('comment.show.less') }}
</a>
</div>
</div>
<ds-space margin-bottom="small" />
</ds-card>
</div>
</template>
<!-- eslint-enable vue/no-v-html -->
<script>
import gql from 'graphql-tag'
import { mapGetters } from 'vuex'
import { mapGetters, mapMutations } from 'vuex'
import HcUser from '~/components/User'
import ContentMenu from '~/components/ContentMenu'
import HcEditCommentForm from '~/components/comments/EditCommentForm/EditCommentForm'
export default {
data: function() {
return {
isCollapsed: true,
openEditCommentMenu: false,
}
},
components: {
HcUser,
ContentMenu,
HcEditCommentForm,
},
props: {
post: { type: Object, default: () => {} },
comment: {
type: Object,
default() {
@ -112,9 +124,16 @@ export default {
},
},
methods: {
...mapMutations({
setEditPending: 'editor/SET_EDIT_PENDING',
}),
isAuthor(id) {
return this.user.id === id
},
editCommentMenu(showMenu) {
this.openEditCommentMenu = showMenu
this.setEditPending(showMenu)
},
async deleteCommentCallback() {
try {
var gqlMutation = gql`

View File

@ -76,14 +76,13 @@ export default {
}
if (this.isOwner && this.resourceType === 'comment') {
// routes.push({
// name: this.$t(`comment.menu.edit`),
// callback: () => {
// /* eslint-disable-next-line no-console */
// console.log('EDIT COMMENT')
// },
// icon: 'edit'
// })
routes.push({
name: this.$t(`comment.menu.edit`),
callback: () => {
this.$emit('showEditCommentMenu', true)
},
icon: 'edit',
})
routes.push({
name: this.$t(`comment.menu.delete`),
callback: () => {

View File

@ -1,5 +1,5 @@
<template>
<ds-form v-model="form" @submit="handleSubmit">
<ds-form v-show="!editPending" v-model="form" @submit="handleSubmit">
<template slot-scope="{ errors }">
<ds-card>
<hc-editor ref="editor" :users="users" :value="form.content" @input="updateEditorContent" />
@ -27,6 +27,7 @@ import gql from 'graphql-tag'
import HcEditor from '~/components/Editor/Editor'
import PostCommentsQuery from '~/graphql/PostCommentsQuery.js'
import CommentMutations from '~/graphql/CommentMutations.js'
import { mapGetters } from 'vuex'
export default {
components: {
@ -46,6 +47,11 @@ export default {
users: [],
}
},
computed: {
...mapGetters({
editPending: 'editor/editPending',
}),
},
methods: {
updateEditorContent(value) {
const content = value.replace(/<(?:.|\n)*?>/gm, '').trim()

View File

@ -40,6 +40,7 @@ describe('CommentForm.vue', () => {
'editor/placeholder': () => {
return 'some cool placeholder'
},
'editor/editPending': () => false,
}
const store = new Vuex.Store({
getters,

View File

@ -16,11 +16,12 @@
</span>
</h3>
<ds-space margin-bottom="large" />
<div v-if="comments && comments.length" class="comments">
<div v-if="comments && comments.length" id="comments" class="comments">
<comment
v-for="(comment, index) in comments"
:key="comment.id"
:comment="comment"
:post="post"
@deleteComment="comments.splice(index, 1)"
/>
</div>

View File

@ -0,0 +1,108 @@
<template>
<ds-form v-model="form" @submit="handleSubmit">
<template slot-scope="{ errors }">
<ds-card>
<!-- with no-ssr the content is not shown -->
<hc-editor ref="editor" :users="users" :value="form.content" @input="updateEditorContent" />
<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 ghost class="cancelBtn" @click.prevent="closeEditWindow">
{{ $t('actions.cancel') }}
</ds-button>
</ds-flex-item>
<ds-flex-item :width="{ base: '40%', md: '20%', sm: '40%', xs: '40%' }">
<ds-button type="submit" :loading="loading" :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/Editor'
import { mapMutations } from 'vuex'
import CommentMutations from '~/graphql/CommentMutations.js'
export default {
components: {
HcEditor,
},
props: {
comment: {
type: Object,
default() {
return {}
},
},
},
data() {
return {
disabled: true,
loading: false,
form: {
content: this.comment.content,
},
users: [],
}
},
methods: {
...mapMutations({
setEditPending: 'editor/SET_EDIT_PENDING',
}),
updateEditorContent(value) {
const sanitizedContent = value.replace(/<(?:.|\n)*?>/gm, '').trim()
this.disabled = value === this.comment.content || sanitizedContent.length < 1
this.form.content = value
},
closeEditWindow() {
this.$emit('showEditCommentMenu', false)
},
handleSubmit() {
this.loading = true
this.disabled = true
this.$apollo
.mutate({
mutation: CommentMutations().UpdateComment,
variables: {
content: this.form.content,
id: this.comment.id,
},
})
.then(() => {
this.loading = false
this.$toast.success(this.$t('post.comment.updated'))
this.disabled = false
this.$emit('showEditCommentMenu', false)
this.setEditPending(false)
})
.catch(err => {
this.$toast.error(err.message)
})
},
},
apollo: {
User: {
query() {
return gql`
{
User(orderBy: slug_asc) {
id
slug
}
}
`
},
result({ data: { User } }) {
this.users = User
},
},
},
}
</script>

View File

@ -20,5 +20,14 @@ export default () => {
}
}
`,
UpdateComment: gql`
mutation($content: String!, $id: ID!) {
UpdateComment(content: $content, id: $id) {
id
content
contentExcerpt
}
}
`,
}
}

View File

@ -2,7 +2,7 @@ import gql from 'graphql-tag'
export default app => {
const lang = app.$i18n.locale().toUpperCase()
return gql(`
return gql`
query Comment($postId: ID) {
Comment(postId: $postId) {
id
@ -30,5 +30,5 @@ export default app => {
}
}
}
`)
`
}

View File

@ -2,7 +2,7 @@ import gql from 'graphql-tag'
export default i18n => {
const lang = i18n.locale().toUpperCase()
return gql(`
return gql`
query Post($slug: String!) {
Post(slug: $slug) {
id
@ -73,12 +73,12 @@ export default i18n => {
shoutedByCurrentUser
}
}
`)
`
}
export const filterPosts = i18n => {
const lang = i18n.locale().toUpperCase()
return gql(`
return gql`
query Post($filter: _PostFilter, $first: Int, $offset: Int) {
Post(filter: $filter, first: $first, offset: $offset) {
id
@ -118,5 +118,5 @@ export const filterPosts = i18n => {
shoutedCount
}
}
`)
`
}

View File

@ -246,7 +246,8 @@
},
"comment": {
"submit": "Comment",
"submitted": "Comment Submitted"
"submitted": "Comment Submitted",
"updated": "Changes Saved"
}
},
"comment": {

View File

@ -1,6 +1,7 @@
export const state = () => {
return {
placeholder: null,
editPending: false,
}
}
@ -8,10 +9,16 @@ export const getters = {
placeholder(state) {
return state.placeholder
},
editPending(state) {
return state.editPending
},
}
export const mutations = {
SET_PLACEHOLDER_TEXT(state, text) {
state.placeholder = text
},
SET_EDIT_PENDING(state, boolean) {
state.editPending = boolean
},
}