diff --git a/backend/src/middleware/notifications/spec.js b/backend/src/middleware/notifications/spec.js index 985654b0f..d214a5571 100644 --- a/backend/src/middleware/notifications/spec.js +++ b/backend/src/middleware/notifications/spec.js @@ -88,21 +88,26 @@ describe('currentUser { notifications }', () => { describe('who mentions me again', () => { beforeEach(async () => { const updatedContent = `${post.content} One more mention to @al-capone` + const updatedTitle = 'this post has been updated' // The response `post.content` contains a link but the XSSmiddleware // should have the `mention` CSS class removed. I discovered this // during development and thought: A feature not a bug! This way we // can encode a re-mentioning of users when you edit your post or // comment. - const createPostMutation = ` - mutation($id: ID!, $content: String!) { - UpdatePost(id: $id, content: $content) { + const updatePostMutation = ` + mutation($id: ID!, $title: String!, $content: String!) { + UpdatePost(id: $id, title: $title, content: $content) { title content } } ` authorClient = new GraphQLClient(host, { headers: authorHeaders }) - await authorClient.request(createPostMutation, { id: post.id, content: updatedContent }) + await authorClient.request(updatePostMutation, { + id: post.id, + content: updatedContent, + title: updatedTitle, + }) }) it('creates exactly one more notification', async () => { diff --git a/backend/src/middleware/sluggifyMiddleware.js b/backend/src/middleware/sluggifyMiddleware.js index 2b1f25d5c..226bef8e5 100644 --- a/backend/src/middleware/sluggifyMiddleware.js +++ b/backend/src/middleware/sluggifyMiddleware.js @@ -17,6 +17,10 @@ export default { args.slug = args.slug || (await uniqueSlug(args.title, isUniqueFor(context, 'Post'))) return resolve(root, args, context, info) }, + UpdatePost: async (resolve, root, args, context, info) => { + args.slug = args.slug || (await uniqueSlug(args.title, isUniqueFor(context, 'Post'))) + return resolve(root, args, context, info) + }, CreateUser: async (resolve, root, args, context, info) => { args.slug = args.slug || (await uniqueSlug(args.name, isUniqueFor(context, 'User'))) return resolve(root, args, context, info) diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index a19e813ad..261ca8caa 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -1,12 +1,43 @@ -import { neo4jgraphql } from 'neo4j-graphql-js' import uuid from 'uuid/v4' import fileUpload from './fileUpload' export default { Mutation: { UpdatePost: async (object, params, context, resolveInfo) => { + const { id: postId, categoryIds } = params + delete params.categoryIds params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) - return neo4jgraphql(object, params, context, resolveInfo, false) + + const session = context.driver.session() + const cypherDeletePreviousRelations = ` + MATCH (post:Post { id: $postId })-[previousRelations:CATEGORIZED]->(category:Category) + DELETE previousRelations + RETURN post, category + ` + + await session.run(cypherDeletePreviousRelations, { postId }) + + let updatePostCypher = `MATCH (post:Post {id: $postId}) + SET post = $params + ` + if (categoryIds) { + updatePostCypher += `WITH post + UNWIND $categoryIds AS categoryId + MATCH (category:Category {id: categoryId}) + MERGE (post)-[:CATEGORIZED]->(category) + ` + } + updatePostCypher += `RETURN post` + const updatePostVariables = { postId, categoryIds, params } + + const transactionRes = await session.run(updatePostCypher, updatePostVariables) + const [post] = transactionRes.records.map(record => { + return record.get('post') + }) + + session.close() + + return post.properties }, CreatePost: async (object, params, context, resolveInfo) => { @@ -14,23 +45,23 @@ export default { delete params.categoryIds params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) params.id = params.id || uuid() - let cypher = `CREATE (post:Post {params}) + let createPostCypher = `CREATE (post:Post {params}) WITH post MATCH (author:User {id: $userId}) MERGE (post)<-[:WROTE]-(author) ` if (categoryIds) { - cypher += `WITH post + createPostCypher += `WITH post UNWIND $categoryIds AS categoryId MATCH (category:Category {id: categoryId}) MERGE (post)-[:CATEGORIZED]->(category) ` } - cypher += `RETURN post` - const variables = { userId: context.user.id, categoryIds, params } + createPostCypher += `RETURN post` + const createPostVariables = { userId: context.user.id, categoryIds, params } const session = context.driver.session() - const transactionRes = await session.run(cypher, variables) + const transactionRes = await session.run(createPostCypher, createPostVariables) const [post] = transactionRes.records.map(record => { return record.get('post') diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index def99d8bb..763945527 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -6,7 +6,32 @@ const factory = Factory() let client const postTitle = 'I am a title' const postContent = 'Some content' +const oldTitle = 'Old title' +const oldContent = 'Old content' +const newTitle = 'New title' +const newContent = 'New content' const createPostVariables = { title: postTitle, content: postContent } +const createPostWithCategoriesMutation = ` + mutation($title: String!, $content: String!, $categoryIds: [ID]) { + CreatePost(title: $title, content: $content, categoryIds: $categoryIds) { + id + } + } +` +const creatPostWithCategoriesVariables = { + title: postTitle, + content: postContent, + categoryIds: ['cat9', 'cat4', 'cat15'], +} +const postQueryWithCategories = ` + query($id: ID) { + Post(id: $id) { + categories { + id + } + } + } +` beforeEach(async () => { await factory.create('User', { email: 'test@example.org', @@ -117,38 +142,7 @@ describe('CreatePost', () => { icon: 'shopping-cart', }), ]) - const createPostWithCategoriesMutation = ` - mutation($title: String!, $content: String!, $categoryIds: [ID]) { - CreatePost(title: $title, content: $content, categoryIds: $categoryIds) { - id - } - } - ` - const creatPostWithCategoriesVariables = { - title: postTitle, - content: postContent, - categoryIds: ['cat9', 'cat4', 'cat15'], - } - const postQueryWithCategories = ` - query($id: ID) { - Post(id: $id) { - categories { - id - } - } - } - ` - const expected = { - Post: [ - { - categories: [ - { id: expect.any(String) }, - { id: expect.any(String) }, - { id: expect.any(String) }, - ], - }, - ], - } + const expected = [{ id: 'cat9' }, { id: 'cat4' }, { id: 'cat15' }] const postWithCategories = await client.request( createPostWithCategoriesMutation, creatPostWithCategoriesVariables, @@ -158,27 +152,15 @@ describe('CreatePost', () => { } await expect( client.request(postQueryWithCategories, postQueryWithCategoriesVariables), - ).resolves.toEqual(expect.objectContaining(expected)) + ).resolves.toEqual({ Post: [{ categories: expect.arrayContaining(expected) }] }) }) }) }) }) describe('UpdatePost', () => { - const mutation = ` - mutation($id: ID!, $content: String) { - UpdatePost(id: $id, content: $content) { - id - content - } - } - ` - - let variables = { - id: 'p1', - content: 'New content', - } - + let updatePostMutation + let updatePostVariables beforeEach(async () => { const asAuthor = Factory() await asAuthor.create('User', { @@ -191,14 +173,32 @@ describe('UpdatePost', () => { }) await asAuthor.create('Post', { id: 'p1', - content: 'Old content', + title: oldTitle, + content: oldContent, }) + updatePostMutation = ` + mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID]) { + UpdatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) { + id + content + } + } + ` + + updatePostVariables = { + id: 'p1', + title: newTitle, + content: newContent, + categoryIds: null, + } }) describe('unauthenticated', () => { it('throws authorization error', async () => { client = new GraphQLClient(host) - await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') + await expect(client.request(updatePostMutation, updatePostVariables)).rejects.toThrow( + 'Not Authorised', + ) }) }) @@ -210,7 +210,9 @@ describe('UpdatePost', () => { }) it('throws authorization error', async () => { - await expect(client.request(mutation, variables)).rejects.toThrow('Not Authorised') + await expect(client.request(updatePostMutation, updatePostVariables)).rejects.toThrow( + 'Not Authorised', + ) }) }) @@ -222,8 +224,59 @@ describe('UpdatePost', () => { }) it('updates a post', async () => { - const expected = { UpdatePost: { id: 'p1', content: 'New content' } } - await expect(client.request(mutation, variables)).resolves.toEqual(expected) + const expected = { UpdatePost: { id: 'p1', content: newContent } } + await expect(client.request(updatePostMutation, updatePostVariables)).resolves.toEqual( + expected, + ) + }) + + describe('categories', () => { + let postWithCategories + beforeEach(async () => { + await Promise.all([ + factory.create('Category', { + id: 'cat9', + name: 'Democracy & Politics', + icon: 'university', + }), + factory.create('Category', { + id: 'cat4', + name: 'Environment & Nature', + icon: 'tree', + }), + factory.create('Category', { + id: 'cat15', + name: 'Consumption & Sustainability', + icon: 'shopping-cart', + }), + factory.create('Category', { + id: 'cat27', + name: 'Animal Protection', + icon: 'paw', + }), + ]) + postWithCategories = await client.request( + createPostWithCategoriesMutation, + creatPostWithCategoriesVariables, + ) + updatePostVariables = { + id: postWithCategories.CreatePost.id, + title: newTitle, + content: newContent, + categoryIds: ['cat27'], + } + }) + + it('allows a user to update the categories of a post', async () => { + await client.request(updatePostMutation, updatePostVariables) + const expected = [{ id: 'cat27' }] + const postQueryWithCategoriesVariables = { + id: postWithCategories.CreatePost.id, + } + await expect( + client.request(postQueryWithCategories, postQueryWithCategoriesVariables), + ).resolves.toEqual({ Post: [{ categories: expect.arrayContaining(expected) }] }) + }) }) }) }) diff --git a/backend/src/schema/types/schema.gql b/backend/src/schema/types/schema.gql index 6588bb264..b043a5c27 100644 --- a/backend/src/schema/types/schema.gql +++ b/backend/src/schema/types/schema.gql @@ -58,6 +58,24 @@ type Mutation { categoryIds: [ID] contentExcerpt: String ): Post + UpdatePost( + id: ID! + activityId: String + objectId: String + title: String! + slug: String + content: String! + contentExcerpt: String + image: String + imageUpload: Upload + visibility: Visibility + deleted: Boolean + disabled: Boolean + createdAt: String + updatedAt: String + language: String + categoryIds: [ID] + ): Post DeleteUser(id: ID!, resource: [Deletable]): User } diff --git a/webapp/components/CategoriesSelect/CategoriesSelect.vue b/webapp/components/CategoriesSelect/CategoriesSelect.vue index 1212aff4d..163f31419 100644 --- a/webapp/components/CategoriesSelect/CategoriesSelect.vue +++ b/webapp/components/CategoriesSelect/CategoriesSelect.vue @@ -30,6 +30,9 @@ import gql from 'graphql-tag' export default { + props: { + existingCategoryIds: { type: Array, default: () => [] }, + }, data() { return { categories: null, @@ -49,19 +52,28 @@ export default { selectedCategoryIds(categoryIds) { this.$emit('updateCategories', categoryIds) }, + existingCategoryIds: { + immediate: true, + handler: function(existingCategoryIds) { + if (!existingCategoryIds || !existingCategoryIds.length) { + return + } + this.selectedCategoryIds = existingCategoryIds + }, + }, }, methods: { toggleCategory(id) { const index = this.selectedCategoryIds.indexOf(id) if (index > -1) { - this.selectedCategoryIds.splice(index) + this.selectedCategoryIds.splice(index, 1) } else { this.selectedCategoryIds.push(id) } }, isActive(id) { - const activeCategory = this.selectedCategoryIds.find(categoryId => categoryId === id) - if (activeCategory) { + const index = this.selectedCategoryIds.indexOf(id) + if (index > -1) { return true } return false diff --git a/webapp/components/ContributionForm/ContributionForm.spec.js b/webapp/components/ContributionForm/ContributionForm.spec.js index c781df3a4..2b47c5a06 100644 --- a/webapp/components/ContributionForm/ContributionForm.spec.js +++ b/webapp/components/ContributionForm/ContributionForm.spec.js @@ -1,5 +1,5 @@ import { config, mount, createLocalVue } from '@vue/test-utils' -import ContributionForm from './index.vue' +import ContributionForm from './ContributionForm.vue' import Styleguide from '@human-connection/styleguide' import Vuex from 'vuex' import PostMutations from '~/graphql/PostMutations.js' @@ -29,7 +29,7 @@ describe('ContributionForm.vue', () => { file: { filename: 'avataar.svg', previewElement: '' }, url: 'someUrlToImage', } - + const image = '/uploads/1562010976466-avataaars' beforeEach(() => { mocks = { $t: jest.fn(), @@ -115,6 +115,7 @@ describe('ContributionForm.vue', () => { id: null, categoryIds: null, imageUpload: null, + image: null, }, } postTitleInput = wrapper.find('.ds-input') @@ -143,6 +144,8 @@ describe('ContributionForm.vue', () => { const categoryIds = ['cat12', 'cat15', 'cat37'] expectedParams.variables.categoryIds = categoryIds wrapper.find(CategoriesSelect).vm.$emit('updateCategories', categoryIds) + await wrapper.find('form').trigger('submit') + expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) }) it('supports adding a teaser image', async () => { @@ -197,7 +200,8 @@ describe('ContributionForm.vue', () => { title: 'dies ist ein Post', content: 'auf Deutsch geschrieben', language: 'de', - imageUpload, + image, + categories: [{ id: 'cat12', name: 'Democracy & Politics' }], }, } wrapper = Wrapper() @@ -227,8 +231,9 @@ describe('ContributionForm.vue', () => { content: postContent, language: propsData.contribution.language, id: propsData.contribution.id, - categoryIds: null, - imageUpload, + categoryIds: ['cat12'], + image, + imageUpload: null, }, } postTitleInput = wrapper.find('.ds-input') @@ -237,6 +242,17 @@ describe('ContributionForm.vue', () => { await wrapper.find('form').trigger('submit') expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) }) + + it('supports updateing categories', async () => { + const categoryIds = ['cat3', 'cat51', 'cat37'] + postTitleInput = wrapper.find('.ds-input') + postTitleInput.setValue(postTitle) + wrapper.vm.updateEditorContent(postContent) + expectedParams.variables.categoryIds = categoryIds + wrapper.find(CategoriesSelect).vm.$emit('updateCategories', categoryIds) + await wrapper.find('form').trigger('submit') + expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) + }) }) }) }) diff --git a/webapp/components/ContributionForm/index.vue b/webapp/components/ContributionForm/ContributionForm.vue similarity index 89% rename from webapp/components/ContributionForm/index.vue rename to webapp/components/ContributionForm/ContributionForm.vue index 6af1f0d4b..c6bb2cdc4 100644 --- a/webapp/components/ContributionForm/index.vue +++ b/webapp/components/ContributionForm/ContributionForm.vue @@ -14,7 +14,11 @@ - + @@ -33,7 +37,7 @@ :disabled="loading || disabled" ghost class="cancel-button" - @click="$router.back()" + @click.prevent="$router.back()" > {{ $t('actions.cancel') }} @@ -77,6 +81,7 @@ export default { title: '', content: '', teaserImage: null, + image: null, language: null, languageOptions: [], categoryIds: null, @@ -103,7 +108,8 @@ export default { this.slug = contribution.slug this.form.content = contribution.content this.form.title = contribution.title - this.form.teaserImage = contribution.imageUpload + this.form.image = contribution.image + this.form.categoryIds = this.categoryIds(contribution.categories) }, }, }, @@ -121,7 +127,7 @@ export default { }, methods: { submit() { - const { title, content, teaserImage, categoryIds } = this.form + const { title, content, image, teaserImage, categoryIds } = this.form let language if (this.form.language) { language = this.form.language.value @@ -140,6 +146,7 @@ export default { content, categoryIds, language, + image, imageUpload: teaserImage, }, }) @@ -175,6 +182,13 @@ export default { addTeaserImage(file) { this.form.teaserImage = file }, + categoryIds(categories) { + let categoryIds = [] + categories.map(categoryId => { + categoryIds.push(categoryId.id) + }) + return categoryIds + }, }, apollo: { User: { diff --git a/webapp/graphql/PostMutations.js b/webapp/graphql/PostMutations.js index 119c60352..0aac535ca 100644 --- a/webapp/graphql/PostMutations.js +++ b/webapp/graphql/PostMutations.js @@ -18,10 +18,11 @@ export default () => { imageUpload: $imageUpload ) { title + slug content contentExcerpt language - imageUpload + image } } `, @@ -32,6 +33,8 @@ export default () => { $content: String! $language: String $imageUpload: Upload + $categoryIds: [ID] + $image: String ) { UpdatePost( id: $id @@ -39,6 +42,8 @@ export default () => { content: $content language: $language imageUpload: $imageUpload + categoryIds: $categoryIds + image: $image ) { id title @@ -46,7 +51,7 @@ export default () => { content contentExcerpt language - imageUpload + image } } `, diff --git a/webapp/pages/post/create.vue b/webapp/pages/post/create.vue index a5781032b..c6b76287f 100644 --- a/webapp/pages/post/create.vue +++ b/webapp/pages/post/create.vue @@ -8,7 +8,7 @@