diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index 31c373fc7..7958e610d 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -176,6 +176,7 @@ const permissions = shield( enable: isModerator, disable: isModerator, CreateComment: isAuthenticated, + UpdateComment: isAuthor, DeleteComment: isAuthor, DeleteUser: isDeletingOwnAccount, requestPasswordReset: allow, diff --git a/backend/src/middleware/validation/index.js b/backend/src/middleware/validation/index.js deleted file mode 100644 index ca7a6b338..000000000 --- a/backend/src/middleware/validation/index.js +++ /dev/null @@ -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, - }, -} diff --git a/backend/src/middleware/validation/validationMiddleware.js b/backend/src/middleware/validation/validationMiddleware.js index 77282b6b5..319a9a6c0 100644 --- a/backend/src/middleware/validation/validationMiddleware.js +++ b/backend/src/middleware/validation/validationMiddleware.js @@ -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, }, } diff --git a/backend/src/schema/resolvers/comments.spec.js b/backend/src/schema/resolvers/comments.spec.js index a12e2dfcc..890ba5feb 100644 --- a/backend/src/schema/resolvers/comments.spec.js +++ b/backend/src/schema/resolvers/comments.spec.js @@ -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: '

', + } + + 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: '

', + } + + 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: '

Hello

', + } + + 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: '

Hello

', + } + + 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) + }) }) }) }) diff --git a/backend/src/schema/types/type/Comment.gql b/backend/src/schema/types/type/Comment.gql index 441fba179..4abebcba6 100644 --- a/backend/src/schema/types/type/Comment.gql +++ b/backend/src/schema/types/type/Comment.gql @@ -24,7 +24,7 @@ type Mutation { ): Comment UpdateComment( id: ID! - content: String + content: String! contentExcerpt: String deleted: Boolean disabled: Boolean diff --git a/webapp/components/Comment.vue b/webapp/components/Comment.vue index d8f63526f..7684c3f75 100644 --- a/webapp/components/Comment.vue +++ b/webapp/components/Comment.vue @@ -23,49 +23,61 @@ :modalsData="menuModalsData" style="float-right" :is-owner="isAuthor(author.id)" + @showEditCommentMenu="editCommentMenu" /> - - -
-
- - {{ $t('comment.show.more') }} - + +
+
-
-
- - {{ $t('comment.show.less') }} - +
+ - diff --git a/webapp/graphql/CommentMutations.js b/webapp/graphql/CommentMutations.js index e47e25d6a..6b0f653fc 100644 --- a/webapp/graphql/CommentMutations.js +++ b/webapp/graphql/CommentMutations.js @@ -20,5 +20,14 @@ export default () => { } } `, + UpdateComment: gql` + mutation($content: String!, $id: ID!) { + UpdateComment(content: $content, id: $id) { + id + content + contentExcerpt + } + } + `, } } diff --git a/webapp/graphql/CommentQuery.js b/webapp/graphql/CommentQuery.js index 3696f1964..4d82165f0 100644 --- a/webapp/graphql/CommentQuery.js +++ b/webapp/graphql/CommentQuery.js @@ -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 => { } } } - `) + ` } diff --git a/webapp/graphql/PostQuery.js b/webapp/graphql/PostQuery.js index cac80d9b1..d2bba23ef 100644 --- a/webapp/graphql/PostQuery.js +++ b/webapp/graphql/PostQuery.js @@ -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 } } -`) +` } diff --git a/webapp/locales/en.json b/webapp/locales/en.json index edd2c6dc6..7f01f3a7b 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -246,7 +246,8 @@ }, "comment": { "submit": "Comment", - "submitted": "Comment Submitted" + "submitted": "Comment Submitted", + "updated": "Changes Saved" } }, "comment": { diff --git a/webapp/store/editor.js b/webapp/store/editor.js index 9c5f665a0..cbf9d9289 100644 --- a/webapp/store/editor.js +++ b/webapp/store/editor.js @@ -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 + }, }