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 @@
-
+