Merge pull request #1537 from Human-Connection/1455-fix-update-comment-list

🍰 Fixes a create and update comment problem in the comments list
This commit is contained in:
mattwr18 2019-09-12 18:36:30 +02:00 committed by GitHub
commit fb2cade2f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 311 additions and 224 deletions

View File

@ -33,7 +33,11 @@ describe('Comment.vue', () => {
}, },
$apollo: { $apollo: {
mutate: jest.fn().mockResolvedValue({ mutate: jest.fn().mockResolvedValue({
data: { DeleteComment: { id: 'it-is-the-deleted-comment' } }, data: {
DeleteComment: {
id: 'it-is-the-deleted-comment',
},
},
}), }),
}, },
} }
@ -125,7 +129,11 @@ describe('Comment.vue', () => {
it('emits "deleteComment"', () => { it('emits "deleteComment"', () => {
expect(wrapper.emitted('deleteComment')).toEqual([ expect(wrapper.emitted('deleteComment')).toEqual([
[{ id: 'it-is-the-deleted-comment' }], [
{
id: 'it-is-the-deleted-comment',
},
],
]) ])
}) })
@ -138,6 +146,30 @@ describe('Comment.vue', () => {
}) })
}) })
}) })
describe('test update comment', () => {
beforeEach(() => {
wrapper = Wrapper()
})
describe('with a given comment', () => {
beforeEach(async () => {
await wrapper.vm.updateComment({
id: 'it-is-the-updated-comment',
})
})
it('emits "updateComment"', () => {
expect(wrapper.emitted('updateComment')).toEqual([
[
{
id: 'it-is-the-updated-comment',
},
],
])
})
})
})
}) })
}) })
}) })

View File

@ -13,26 +13,29 @@
<ds-card :id="`commentId-${comment.id}`"> <ds-card :id="`commentId-${comment.id}`">
<ds-space margin-bottom="small"> <ds-space margin-bottom="small">
<hc-user :user="author" :date-time="comment.createdAt" /> <hc-user :user="author" :date-time="comment.createdAt" />
<!-- Content Menu (can open Modals) -->
<client-only>
<content-menu
v-show="!openEditCommentMenu"
placement="bottom-end"
resource-type="comment"
:resource="comment"
:modalsData="menuModalsData"
style="float-right"
:is-owner="isAuthor(author.id)"
@showEditCommentMenu="editCommentMenu"
/>
</client-only>
</ds-space> </ds-space>
<!-- Content Menu (can open Modals) -->
<client-only>
<content-menu
placement="bottom-end"
resource-type="comment"
:resource="comment"
:modalsData="menuModalsData"
style="float-right"
:is-owner="isAuthor(author.id)"
@showEditCommentMenu="editCommentMenu"
/>
</client-only>
<ds-space margin-bottom="small" /> <ds-space margin-bottom="small" />
<div v-if="openEditCommentMenu"> <div v-if="openEditCommentMenu">
<hc-edit-comment-form <hc-comment-form
:comment="comment" :update="true"
:post="post" :post="post"
:comment="comment"
@showEditCommentMenu="editCommentMenu" @showEditCommentMenu="editCommentMenu"
@updateComment="updateComment"
/> />
</div> </div>
<div v-show="!openEditCommentMenu"> <div v-show="!openEditCommentMenu">
@ -66,7 +69,7 @@ import { mapGetters } from 'vuex'
import HcUser from '~/components/User/User' import HcUser from '~/components/User/User'
import ContentMenu from '~/components/ContentMenu' import ContentMenu from '~/components/ContentMenu'
import ContentViewer from '~/components/Editor/ContentViewer' import ContentViewer from '~/components/Editor/ContentViewer'
import HcEditCommentForm from '~/components/EditCommentForm/EditCommentForm' import HcCommentForm from '~/components/CommentForm/CommentForm'
import CommentMutations from '~/graphql/CommentMutations' import CommentMutations from '~/graphql/CommentMutations'
export default { export default {
@ -80,7 +83,7 @@ export default {
HcUser, HcUser,
ContentMenu, ContentMenu,
ContentViewer, ContentViewer,
HcEditCommentForm, HcCommentForm,
}, },
props: { props: {
post: { type: Object, default: () => {} }, post: { type: Object, default: () => {} },
@ -136,6 +139,9 @@ export default {
editCommentMenu(showMenu) { editCommentMenu(showMenu) {
this.openEditCommentMenu = showMenu this.openEditCommentMenu = showMenu
}, },
updateComment(comment) {
this.$emit('updateComment', comment)
},
async deleteCommentCallback() { async deleteCommentCallback() {
try { try {
const { const {

View File

@ -12,8 +12,8 @@ describe('CommentForm.vue', () => {
let mocks let mocks
let wrapper let wrapper
let propsData let propsData
let cancelBtn
let cancelMethodSpy let cancelMethodSpy
let closeMethodSpy
beforeEach(() => { beforeEach(() => {
mocks = { mocks = {
@ -21,79 +21,180 @@ describe('CommentForm.vue', () => {
$i18n: { $i18n: {
locale: () => 'en', locale: () => 'en',
}, },
$apollo: {
mutate: jest
.fn()
.mockResolvedValueOnce({
data: {
CreateComment: {
contentExcerpt: 'this is a comment',
},
},
})
.mockRejectedValue({
message: 'Ouch!',
}),
},
$toast: { $toast: {
error: jest.fn(), error: jest.fn(),
success: jest.fn(), success: jest.fn(),
}, },
} $filters: {
propsData = { removeHtml: a => a,
post: {
id: 1,
}, },
} }
}) })
describe('mount', () => { describe('mount', () => {
const Wrapper = () => { describe('create comment', () => {
return mount(CommentForm, { beforeEach(() => {
mocks, mocks = {
localVue, ...mocks,
propsData, $apollo: {
mutate: jest
.fn()
.mockResolvedValueOnce({
data: {
CreateComment: {
contentExcerpt: 'this is a comment',
},
},
})
.mockRejectedValue({
message: 'Ouch!',
}),
},
}
propsData = {
post: {
id: 'p001',
},
}
const Wrapper = () => {
return mount(CommentForm, {
mocks,
localVue,
propsData,
})
}
wrapper = Wrapper()
cancelMethodSpy = jest.spyOn(wrapper.vm, 'clear')
}) })
}
beforeEach(() => { it('calls the apollo mutation when form is submitted', async () => {
wrapper = Wrapper()
cancelMethodSpy = jest.spyOn(wrapper.vm, 'clear')
})
it('calls the apollo mutation when form is submitted', async () => {
wrapper.vm.updateEditorContent('this is a comment')
await wrapper.find('form').trigger('submit')
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
})
it('calls clear method when the cancel button is clicked', () => {
wrapper.vm.updateEditorContent('ok')
cancelBtn = wrapper.find('.cancelBtn')
cancelBtn.trigger('click')
expect(cancelMethodSpy).toHaveBeenCalledTimes(1)
})
describe('mutation resolves', () => {
beforeEach(async () => {
wrapper.vm.updateEditorContent('this is a comment') wrapper.vm.updateEditorContent('this is a comment')
wrapper.find('form').trigger('submit') await wrapper.find('form').trigger('submit')
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
}) })
it('shows a success toaster', async () => { it('calls `clear` method when the cancel button is clicked', async () => {
await mocks.$apollo.mutate wrapper.vm.updateEditorContent('ok')
expect(mocks.$toast.success).toHaveBeenCalledTimes(1) await wrapper.find('.cancelBtn').trigger('submit')
})
it('clears the editor', () => {
expect(cancelMethodSpy).toHaveBeenCalledTimes(1) expect(cancelMethodSpy).toHaveBeenCalledTimes(1)
}) })
describe('mutation fails', () => { describe('mutation resolves', () => {
it('shows the error toaster', async () => { beforeEach(async () => {
await wrapper.find('form').trigger('submit') wrapper.vm.updateEditorContent('this is a comment')
wrapper.find('form').trigger('submit')
})
it('shows a success toaster', async () => {
await mocks.$apollo.mutate await mocks.$apollo.mutate
expect(mocks.$toast.error).toHaveBeenCalledTimes(1) expect(mocks.$toast.success).toHaveBeenCalledTimes(1)
})
it('clears the editor', () => {
expect(cancelMethodSpy).toHaveBeenCalledTimes(1)
})
describe('mutation fails', () => {
it('shows the error toaster', async () => {
await wrapper.find('form').trigger('submit')
await mocks.$apollo.mutate
expect(mocks.$toast.error).toHaveBeenCalledTimes(1)
})
})
})
})
describe('update comment', () => {
beforeEach(() => {
mocks = {
...mocks,
$apollo: {
mutate: jest
.fn()
.mockResolvedValueOnce({
data: {
UpdateComment: {
contentExcerpt: 'this is a comment',
},
},
})
.mockRejectedValue({
message: 'Ouch!',
}),
},
}
propsData = {
update: true,
comment: {
id: 'c001',
},
}
const Wrapper = () => {
return mount(CommentForm, {
mocks,
localVue,
propsData,
})
}
wrapper = Wrapper()
closeMethodSpy = jest.spyOn(wrapper.vm, 'closeEditWindow')
})
describe('form submitted', () => {
it('calls the apollo mutation', async () => {
wrapper.vm.updateEditorContent('this is a comment')
await wrapper.find('form').trigger('submit')
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
})
it('calls `closeEditWindow` method', async () => {
wrapper.vm.updateEditorContent('ok')
await wrapper.find('form').trigger('submit')
expect(closeMethodSpy).toHaveBeenCalledTimes(1)
})
it('emits `showEditCommentMenu` event', async () => {
wrapper.vm.updateEditorContent('ok')
await wrapper.find('form').trigger('submit')
expect(wrapper.emitted('showEditCommentMenu')).toEqual([[false]])
})
})
describe('cancel button is clicked', () => {
it('calls `closeEditWindow` method', async () => {
wrapper.vm.updateEditorContent('ok')
await wrapper.find('.cancelBtn').trigger('submit')
expect(closeMethodSpy).toHaveBeenCalledTimes(1)
})
it('emits `showEditCommentMenu` event', async () => {
wrapper.vm.updateEditorContent('ok')
await wrapper.find('.cancelBtn').trigger('submit')
expect(wrapper.emitted('showEditCommentMenu')).toEqual([[false]])
})
})
describe('mutation resolves', () => {
beforeEach(async () => {
wrapper.vm.updateEditorContent('this is a comment')
wrapper.find('form').trigger('submit')
})
it('shows a success toaster', async () => {
await mocks.$apollo.mutate
expect(mocks.$toast.success).toHaveBeenCalledTimes(1)
})
it('closes the editor', () => {
expect(closeMethodSpy).toHaveBeenCalledTimes(1)
})
describe('mutation fails', () => {
it('shows the error toaster', async () => {
await wrapper.find('form').trigger('submit')
await mocks.$apollo.mutate
expect(mocks.$toast.error).toHaveBeenCalledTimes(1)
})
}) })
}) })
}) })

View File

@ -2,18 +2,18 @@
<ds-form v-model="form" @submit="handleSubmit"> <ds-form v-model="form" @submit="handleSubmit">
<template slot-scope="{ errors }"> <template slot-scope="{ errors }">
<ds-card> <ds-card>
<hc-editor <!-- with client-only the content is not shown -->
ref="editor" <hc-editor ref="editor" :users="users" :value="form.content" @input="updateEditorContent" />
:users="users"
:hashtags="null"
:value="form.content"
@input="updateEditorContent"
/>
<ds-space /> <ds-space />
<ds-flex :gutter="{ base: 'small', md: 'small', sm: 'x-large', xs: 'x-large' }"> <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: '0%', md: '50%', sm: '0%', xs: '0%' }" />
<ds-flex-item :width="{ base: '40%', md: '20%', sm: '30%', xs: '30%' }"> <ds-flex-item :width="{ base: '40%', md: '20%', sm: '30%', xs: '30%' }">
<ds-button :disabled="disabled" ghost class="cancelBtn" @click.prevent="clear"> <ds-button
:disabled="disabled && !update"
ghost
class="cancelBtn"
@click.prevent="handleCancel"
>
{{ $t('actions.cancel') }} {{ $t('actions.cancel') }}
</ds-button> </ds-button>
</ds-flex-item> </ds-flex-item>
@ -29,9 +29,9 @@
</template> </template>
<script> <script>
import gql from 'graphql-tag'
import HcEditor from '~/components/Editor/Editor' import HcEditor from '~/components/Editor/Editor'
import PostQuery from '~/graphql/PostQuery' import { COMMENT_MIN_LENGTH } from '../../constants/comment'
import { minimisedUserQuery } from '~/graphql/User'
import CommentMutations from '~/graphql/CommentMutations' import CommentMutations from '~/graphql/CommentMutations'
export default { export default {
@ -39,55 +39,94 @@ export default {
HcEditor, HcEditor,
}, },
props: { props: {
update: { type: Boolean, default: () => false },
post: { type: Object, default: () => {} }, post: { type: Object, default: () => {} },
comment: {
type: Object,
default: () => {},
},
}, },
data() { data() {
return { return {
disabled: true, disabled: true,
loading: false, loading: false,
form: { form: {
content: '', content: !this.update || !this.comment.content ? '' : this.comment.content,
}, },
users: [], users: [],
} }
}, },
methods: { methods: {
updateEditorContent(value) { updateEditorContent(value) {
const content = value.replace(/<(?:.|\n)*?>/gm, '').trim() const sanitizedContent = this.$filters.removeHtml(value, false)
if (content.length < 1) { if (!this.update) {
this.disabled = true if (sanitizedContent.length < COMMENT_MIN_LENGTH) {
this.disabled = true
} else {
this.disabled = false
}
} else { } else {
this.disabled = false this.disabled =
value === this.comment.content || sanitizedContent.length < COMMENT_MIN_LENGTH
} }
this.form.content = value this.form.content = value
}, },
clear() { clear() {
this.$refs.editor.clear() this.$refs.editor.clear()
}, },
closeEditWindow() {
this.$emit('showEditCommentMenu', false)
},
handleCancel() {
if (!this.update) {
this.clear()
} else {
this.closeEditWindow()
}
},
handleSubmit() { handleSubmit() {
this.loading = true let mutateParams
this.disabled = true if (!this.update) {
this.$apollo mutateParams = {
.mutate({
mutation: CommentMutations(this.$i18n).CreateComment, mutation: CommentMutations(this.$i18n).CreateComment,
variables: { variables: {
postId: this.post.id, postId: this.post.id,
content: this.form.content, content: this.form.content,
}, },
update: async (store, { data: { CreateComment } }) => { }
const data = await store.readQuery({ } else {
query: PostQuery(this.$i18n), mutateParams = {
variables: { id: this.post.id }, mutation: CommentMutations(this.$i18n).UpdateComment,
}) variables: {
data.Post[0].comments.push(CreateComment) id: this.comment.id,
await store.writeQuery({ query: PostQuery(this.$i18n), data }) content: this.form.content,
}, },
}) }
}
this.loading = true
this.disabled = true
this.$apollo
.mutate(mutateParams)
.then(res => { .then(res => {
this.loading = false this.loading = false
this.clear() if (!this.update) {
this.$toast.success(this.$t('post.comment.submitted')) const {
this.disabled = false data: { CreateComment },
} = res
this.$emit('createComment', CreateComment)
this.clear()
this.$toast.success(this.$t('post.comment.submitted'))
this.disabled = false
} else {
const {
data: { UpdateComment },
} = res
this.$emit('updateComment', UpdateComment)
this.$toast.success(this.$t('post.comment.updated'))
this.disabled = false
this.closeEditWindow()
}
}) })
.catch(err => { .catch(err => {
this.$toast.error(err.message) this.$toast.error(err.message)
@ -97,16 +136,7 @@ export default {
apollo: { apollo: {
User: { User: {
query() { query() {
return gql` return minimisedUserQuery()
{
User(orderBy: slug_asc) {
id
slug
name
avatar
}
}
`
}, },
result(result) { result(result) {
this.users = result.data.User this.users = result.data.User

View File

@ -22,7 +22,8 @@
:key="comment.id" :key="comment.id"
:comment="comment" :comment="comment"
:post="post" :post="post"
@deleteComment="deleteComment" @deleteComment="updateCommentList"
@updateComment="updateCommentList"
/> />
</div> </div>
<hc-empty v-else name="empty" icon="messages" /> <hc-empty v-else name="empty" icon="messages" />
@ -41,9 +42,9 @@ export default {
post: { type: Object, default: () => {} }, post: { type: Object, default: () => {} },
}, },
methods: { methods: {
deleteComment(deleted) { updateCommentList(updatedComment) {
this.post.comments = this.post.comments.map(comment => { this.post.comments = this.post.comments.map(comment => {
return comment.id === deleted.id ? deleted : comment return comment.id === updatedComment.id ? updatedComment : comment
}) })
}, },
}, },

View File

@ -1,103 +0,0 @@
<template>
<ds-form v-model="form" @submit="handleSubmit">
<template slot-scope="{ errors }">
<ds-card>
<!-- with client-only 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 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: {
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(this.$i18n).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)
})
.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

@ -0,0 +1 @@
export const COMMENT_MIN_LENGTH = 1

View File

@ -77,6 +77,19 @@ export default i18n => {
` `
} }
export const minimisedUserQuery = () => {
return gql`
query {
User(orderBy: slug_asc) {
id
slug
name
avatar
}
}
`
}
export const notificationQuery = i18n => { export const notificationQuery = i18n => {
const lang = i18n.locale().toUpperCase() const lang = i18n.locale().toUpperCase()
return gql` return gql`

View File

@ -68,7 +68,7 @@
<ds-section slot="footer"> <ds-section slot="footer">
<hc-comment-list :post="post" /> <hc-comment-list :post="post" />
<ds-space margin-bottom="large" /> <ds-space margin-bottom="large" />
<hc-comment-form :post="post" /> <hc-comment-form :post="post" @createComment="createComment" />
</ds-section> </ds-section>
</ds-card> </ds-card>
</transition> </transition>
@ -151,6 +151,9 @@ export default {
this.$toast.error(err.message) this.$toast.error(err.message)
} }
}, },
async createComment(comment) {
this.post.comments.push(comment)
},
}, },
apollo: { apollo: {
Post: { Post: {

View File

@ -83,10 +83,13 @@ export default ({ app = {} }) => {
return excerpt return excerpt
}, },
removeHtml: content => { removeHtml: (content, replaceLinebreaks = true) => {
if (!content) return '' if (!content) return ''
// replace linebreaks with spaces first let contentExcerpt = content
let contentExcerpt = content.replace(/<br>/gim, ' ').trim() if (replaceLinebreaks) {
// replace linebreaks with spaces first
contentExcerpt = contentExcerpt.replace(/<br>/gim, ' ').trim()
}
// remove the rest of the HTML // remove the rest of the HTML
contentExcerpt = contentExcerpt.replace(/<(?:.|\n)*?>/gm, '').trim() contentExcerpt = contentExcerpt.replace(/<(?:.|\n)*?>/gm, '').trim()