From d714927315df2695f8c83143c689575a5e62bdc3 Mon Sep 17 00:00:00 2001 From: Matt Rider Date: Wed, 26 Jun 2019 11:26:44 -0300 Subject: [PATCH 01/12] Set up backend for Post creation with categories --- backend/src/schema/resolvers/posts.js | 45 +++++++++++- backend/src/schema/resolvers/posts.spec.js | 82 ++++++++++++++++++---- backend/src/schema/types/schema.gql | 17 +++++ 3 files changed, 126 insertions(+), 18 deletions(-) diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index ea962a662..7769fad14 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -9,8 +9,9 @@ export default { }, CreatePost: async (object, params, context, resolveInfo) => { + const { categories } = params params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) - const result = await neo4jgraphql(object, params, context, resolveInfo, false) + let post = await neo4jgraphql(object, params, context, resolveInfo, false) const session = context.driver.session() await session.run( @@ -19,12 +20,50 @@ export default { 'RETURN author', { userId: context.user.id, - postId: result.id, + postId: post.id, }, ) + if (categories && categories.length) { + let postsCategoriesArray = [] + await Promise.all( + categories.map(async categoryId => { + let postsCategoriesTransaction = await session.run( + `MATCH (category:Category { id: $categoryId}), (post:Post {id: $postId}) + MERGE (post)-[:CATEGORIZED]->(category) + RETURN category + `, + { + categoryId, + postId: post.id, + }, + ) + postsCategoriesArray.push(postsCategoriesTransaction) + }), + ) + let categoryArray = [] + postsCategoriesArray.map(categoryRecord => { + let [category] = categoryRecord.records.map(record => { + return { + category: record.get('category'), + } + }) + categoryArray.push(category) + }) + let categoriesPropertiesArray = [] + categoryArray.map(node => { + let { category } = node + let categories = { ...category.properties } + categoriesPropertiesArray.push(categories) + }) + + post = { + ...post, + categories: categoriesPropertiesArray, + } + } session.close() - return result + return post }, }, } diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index 3bff53ddb..56b2c8b95 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -4,7 +4,9 @@ import { host, login } from '../../jest/helpers' const factory = Factory() let client - +const postTitle = 'I am a title' +const postContent = 'Some content' +const createPostVariables = { title: postTitle, content: postContent } beforeEach(async () => { await factory.create('User', { email: 'test@example.org', @@ -18,8 +20,8 @@ afterEach(async () => { describe('CreatePost', () => { const mutation = ` - mutation { - CreatePost(title: "I am a title", content: "Some content") { + mutation($title: String!, $content: String!) { + CreatePost(title: $title, content: $content) { title content slug @@ -32,7 +34,7 @@ describe('CreatePost', () => { describe('unauthenticated', () => { it('throws authorization error', async () => { client = new GraphQLClient(host) - await expect(client.request(mutation)).rejects.toThrow('Not Authorised') + await expect(client.request(mutation, createPostVariables)).rejects.toThrow('Not Authorised') }) }) @@ -46,15 +48,15 @@ describe('CreatePost', () => { it('creates a post', async () => { const expected = { CreatePost: { - title: 'I am a title', - content: 'Some content', + title: postTitle, + content: postContent, }, } - await expect(client.request(mutation)).resolves.toMatchObject(expected) + await expect(client.request(mutation, createPostVariables)).resolves.toMatchObject(expected) }) it('assigns the authenticated user as author', async () => { - await client.request(mutation) + await client.request(mutation, createPostVariables) const { User } = await client.request( `{ User(email:"test@example.org") { @@ -65,29 +67,79 @@ describe('CreatePost', () => { }`, { headers }, ) - expect(User).toEqual([{ contributions: [{ title: 'I am a title' }] }]) + expect(User).toEqual([{ contributions: [{ title: postTitle }] }]) }) describe('disabled and deleted', () => { it('initially false', async () => { const expected = { CreatePost: { disabled: false, deleted: false } } - await expect(client.request(mutation)).resolves.toMatchObject(expected) + await expect(client.request(mutation, createPostVariables)).resolves.toMatchObject(expected) }) }) describe('language', () => { it('allows a user to set the language of the post', async () => { const createPostWithLanguageMutation = ` - mutation { - CreatePost(title: "I am a title", content: "Some content", language: "en") { + mutation($title: String!, $content: String!, $language: String) { + CreatePost(title: $title, content: $content, language: $language) { language } } ` + const createPostWithLanguageVariables = { + title: postTitle, + content: postContent, + language: 'en', + } const expected = { CreatePost: { language: 'en' } } - await expect(client.request(createPostWithLanguageMutation)).resolves.toEqual( - expect.objectContaining(expected), - ) + await expect( + client.request(createPostWithLanguageMutation, createPostWithLanguageVariables), + ).resolves.toEqual(expect.objectContaining(expected)) + }) + }) + + describe('categories', () => { + it('allows a user to set the categories of the post', 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', + }), + ]) + const createPostWithCategoriesMutation = ` + mutation($title: String!, $content: String!, $categories: [ID]) { + CreatePost(title: $title, content: $content, categories: $categories) { + categories { + id + } + } + } + ` + const postCategories = ['cat9', 'cat4', 'cat15'] + const creatPostWithCategoriesVariables = { + title: postTitle, + content: postContent, + categories: postCategories, + } + const expected = { + CreatePost: { + categories: [{ id: 'cat9' }, { id: 'cat4' }, { id: 'cat15' }], + }, + } + await expect( + client.request(createPostWithCategoriesMutation, creatPostWithCategoriesVariables), + ).resolves.toEqual(expect.objectContaining(expected)) }) }) }) diff --git a/backend/src/schema/types/schema.gql b/backend/src/schema/types/schema.gql index 1ef83bac3..6a6191313 100644 --- a/backend/src/schema/types/schema.gql +++ b/backend/src/schema/types/schema.gql @@ -41,6 +41,23 @@ type Mutation { # Unfollow the given Type and ID unfollow(id: ID!, type: FollowTypeEnum): Boolean! DeleteUser(id: ID!, resource: [String]): User + CreatePost( + id: ID + activityId: String + objectId: String + title: String! + slug: String + content: String! + image: String + imageUpload: Upload + visibility: Visibility + deleted: Boolean + disabled: Boolean + createdAt: String + updatedAt: String + language: String + categories: [ID] + ): Post } type Statistics { From 46b83c540d80d0edd5949aaf9ce64966678d155d Mon Sep 17 00:00:00 2001 From: Matt Rider Date: Wed, 26 Jun 2019 13:53:11 -0300 Subject: [PATCH 02/12] Add contentExcerpt to CreatePost Mutation --- backend/src/schema/types/schema.gql | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/schema/types/schema.gql b/backend/src/schema/types/schema.gql index 6a6191313..3955da8a2 100644 --- a/backend/src/schema/types/schema.gql +++ b/backend/src/schema/types/schema.gql @@ -57,6 +57,7 @@ type Mutation { updatedAt: String language: String categories: [ID] + contentExcerpt: String ): Post } From 07576d084ca52f5db0b5dd4f4a37536ddc5ff576 Mon Sep 17 00:00:00 2001 From: Matt Rider Date: Wed, 26 Jun 2019 15:59:57 -0300 Subject: [PATCH 03/12] Get categories working with CreatePost --- .../src/middleware/permissionsMiddleware.js | 2 +- backend/src/schema/resolvers/posts.js | 1 - .../CategoriesSelect/CategoriesSelect.vue | 90 +++++++++++++++++++ webapp/components/ContributionForm/index.vue | 16 +++- webapp/graphql/PostMutations.js | 7 +- webapp/locales/en.json | 5 +- 6 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 webapp/components/CategoriesSelect/CategoriesSelect.vue diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index dbcde849c..66c61c12e 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -105,7 +105,7 @@ const permissions = shield( Query: { '*': deny, findPosts: allow, - Category: isAdmin, + Category: allow, Tag: isAdmin, Report: isModerator, Notification: isAdmin, diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index 7769fad14..d585bcfc0 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -62,7 +62,6 @@ export default { } } session.close() - return post }, }, diff --git a/webapp/components/CategoriesSelect/CategoriesSelect.vue b/webapp/components/CategoriesSelect/CategoriesSelect.vue new file mode 100644 index 000000000..1212aff4d --- /dev/null +++ b/webapp/components/CategoriesSelect/CategoriesSelect.vue @@ -0,0 +1,90 @@ + + + diff --git a/webapp/components/ContributionForm/index.vue b/webapp/components/ContributionForm/index.vue index c925a6dca..670960df5 100644 --- a/webapp/components/ContributionForm/index.vue +++ b/webapp/components/ContributionForm/index.vue @@ -7,6 +7,7 @@ + @@ -50,10 +51,12 @@ import HcEditor from '~/components/Editor' import orderBy from 'lodash/orderBy' import locales from '~/locales' import PostMutations from '~/graphql/PostMutations.js' +import HcCategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect' export default { components: { HcEditor, + HcCategoriesSelect, }, props: { contribution: { type: Object, default: () => {} }, @@ -65,6 +68,7 @@ export default { content: '', language: null, languageOptions: [], + categories: null, }, formSchema: { title: { required: true, min: 3, max: 64 }, @@ -106,22 +110,23 @@ export default { }, methods: { submit() { + const { title, content, language, categories } = this.form this.loading = true this.$apollo .mutate({ mutation: this.id ? PostMutations().UpdatePost : PostMutations().CreatePost, variables: { id: this.id, - title: this.form.title, - content: this.form.content, - language: this.form.language ? this.form.language.value : this.$i18n.locale(), + title, + content, + language: language ? language.value : this.$i18n.locale(), + categories, }, }) .then(res => { this.loading = false this.$toast.success(this.$t('contribution.success')) this.disabled = true - const result = res.data[this.id ? 'UpdatePost' : 'CreatePost'] this.$router.push({ @@ -144,6 +149,9 @@ export default { this.form.languageOptions.push({ label: locale.name, value: locale.code }) }) }, + updateCategories(ids) { + this.form.categories = ids + }, }, apollo: { User: { diff --git a/webapp/graphql/PostMutations.js b/webapp/graphql/PostMutations.js index ad629bee7..4f195eb99 100644 --- a/webapp/graphql/PostMutations.js +++ b/webapp/graphql/PostMutations.js @@ -3,14 +3,17 @@ import gql from 'graphql-tag' export default () => { return { CreatePost: gql` - mutation($title: String!, $content: String!, $language: String) { - CreatePost(title: $title, content: $content, language: $language) { + mutation($title: String!, $content: String!, $language: String, $categories: [ID]) { + CreatePost(title: $title, content: $content, language: $language, categories: $categories) { id title slug content contentExcerpt language + categories { + name + } } } `, diff --git a/webapp/locales/en.json b/webapp/locales/en.json index 2442dc202..8f09c5fe1 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -331,6 +331,9 @@ "filterFollow": "Filter contributions from users I follow", "filterALL": "View all contributions", "success": "Saved!", - "languageSelectLabel": "Language" + "languageSelectLabel": "Language", + "categories": { + "infoSelectedNoOfMaxCategories": "{chosen} of {max} categories selected" + } } } From bb40e9d4326ed09ca519d747486faf9aea0b897a Mon Sep 17 00:00:00 2001 From: Matt Rider Date: Wed, 26 Jun 2019 18:21:12 -0300 Subject: [PATCH 04/12] Update component tests for categories --- .../ContributionForm/ContributionForm.spec.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/webapp/components/ContributionForm/ContributionForm.spec.js b/webapp/components/ContributionForm/ContributionForm.spec.js index 6856a64b2..0991a3ea2 100644 --- a/webapp/components/ContributionForm/ContributionForm.spec.js +++ b/webapp/components/ContributionForm/ContributionForm.spec.js @@ -3,6 +3,7 @@ import ContributionForm from './index.vue' import Styleguide from '@human-connection/styleguide' import Vuex from 'vuex' import PostMutations from '~/graphql/PostMutations.js' +import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect' const localVue = createLocalVue() @@ -100,7 +101,13 @@ describe('ContributionForm.vue', () => { beforeEach(async () => { expectedParams = { mutation: PostMutations().CreatePost, - variables: { title: postTitle, content: postContent, language: 'en', id: null }, + variables: { + title: postTitle, + content: postContent, + language: 'en', + id: null, + categories: null, + }, } postTitleInput = wrapper.find('.ds-input') postTitleInput.setValue(postTitle) @@ -124,6 +131,14 @@ describe('ContributionForm.vue', () => { expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) }) + it('supports adding categories', async () => { + const categories = ['cat12', 'cat15', 'cat37'] + expectedParams.variables.categories = categories + wrapper.find(CategoriesSelect).vm.$emit('updateCategories', categories) + await wrapper.find('form').trigger('submit') + expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) + }) + it("pushes the user to the post's page", async () => { expect(mocks.$router.push).toHaveBeenCalledTimes(1) }) @@ -200,6 +215,7 @@ describe('ContributionForm.vue', () => { content: postContent, language: propsData.contribution.language, id: propsData.contribution.id, + categories: null, }, } postTitleInput = wrapper.find('.ds-input') From 351f258fab91df7c54ebff64624e03facd32fe5b Mon Sep 17 00:00:00 2001 From: Matt Rider Date: Thu, 27 Jun 2019 16:57:55 -0300 Subject: [PATCH 05/12] Refactor resolver, write component tests - use UNWIND instead of iterating over categories(cypher magic) - do not return nested categories on post creation as it's expensive and we don't use - refactor backend test - component tests for CategoriesSelect --- backend/src/schema/resolvers/posts.js | 48 ++------ backend/src/schema/resolvers/posts.spec.js | 34 +++++- .../CategoriesSelect/CategoriesSelect.spec.js | 108 ++++++++++++++++++ 3 files changed, 148 insertions(+), 42 deletions(-) create mode 100644 webapp/components/CategoriesSelect/CategoriesSelect.spec.js diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index d585bcfc0..a9617d255 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -10,8 +10,9 @@ export default { CreatePost: async (object, params, context, resolveInfo) => { const { categories } = params + let post params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) - let post = await neo4jgraphql(object, params, context, resolveInfo, false) + post = await neo4jgraphql(object, params, context, resolveInfo, false) const session = context.driver.session() await session.run( @@ -24,42 +25,17 @@ export default { }, ) if (categories && categories.length) { - let postsCategoriesArray = [] - await Promise.all( - categories.map(async categoryId => { - let postsCategoriesTransaction = await session.run( - `MATCH (category:Category { id: $categoryId}), (post:Post {id: $postId}) - MERGE (post)-[:CATEGORIZED]->(category) - RETURN category - `, - { - categoryId, - postId: post.id, - }, - ) - postsCategoriesArray.push(postsCategoriesTransaction) - }), + await session.run( + `MATCH (post:Post {id: $postId}) + UNWIND $categories AS categoryId + MATCH (category:Category {id: categoryId}) + MERGE (post)-[:CATEGORIZED]->(category) + RETURN category`, + { + categories, + postId: post.id, + }, ) - let categoryArray = [] - postsCategoriesArray.map(categoryRecord => { - let [category] = categoryRecord.records.map(record => { - return { - category: record.get('category'), - } - }) - categoryArray.push(category) - }) - let categoriesPropertiesArray = [] - categoryArray.map(node => { - let { category } = node - let categories = { ...category.properties } - categoriesPropertiesArray.push(categories) - }) - - post = { - ...post, - categories: categoriesPropertiesArray, - } } session.close() return post diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index 56b2c8b95..06a342c1c 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -120,25 +120,47 @@ describe('CreatePost', () => { const createPostWithCategoriesMutation = ` mutation($title: String!, $content: String!, $categories: [ID]) { CreatePost(title: $title, content: $content, categories: $categories) { + id categories { id } } } ` - const postCategories = ['cat9', 'cat4', 'cat15'] const creatPostWithCategoriesVariables = { title: postTitle, content: postContent, - categories: postCategories, + categories: ['cat9', 'cat4', 'cat15'], } + const postQueryWithCategories = ` + query($id: ID) { + Post(id: $id) { + categories { + id + } + } + } + ` const expected = { - CreatePost: { - categories: [{ id: 'cat9' }, { id: 'cat4' }, { id: 'cat15' }], - }, + Post: [ + { + categories: [ + { id: expect.any(String) }, + { id: expect.any(String) }, + { id: expect.any(String) }, + ], + }, + ], + } + const postWithCategories = await client.request( + createPostWithCategoriesMutation, + creatPostWithCategoriesVariables, + ) + const postQueryWithCategoriesVariables = { + id: postWithCategories.CreatePost.id, } await expect( - client.request(createPostWithCategoriesMutation, creatPostWithCategoriesVariables), + client.request(postQueryWithCategories, postQueryWithCategoriesVariables), ).resolves.toEqual(expect.objectContaining(expected)) }) }) diff --git a/webapp/components/CategoriesSelect/CategoriesSelect.spec.js b/webapp/components/CategoriesSelect/CategoriesSelect.spec.js new file mode 100644 index 000000000..199dacb74 --- /dev/null +++ b/webapp/components/CategoriesSelect/CategoriesSelect.spec.js @@ -0,0 +1,108 @@ +import { mount, createLocalVue } from '@vue/test-utils' +import CategoriesSelect from './CategoriesSelect' +import Styleguide from '@human-connection/styleguide' + +const localVue = createLocalVue() +localVue.use(Styleguide) + +describe('CategoriesSelect.vue', () => { + let wrapper + let mocks + let democracyAndPolitics + let environmentAndNature + let consumptionAndSustainablity + + const categories = [ + { + id: 'cat9', + name: 'Democracy & Politics', + icon: 'university', + }, + { + id: 'cat4', + name: 'Environment & Nature', + icon: 'tree', + }, + { + id: 'cat15', + name: 'Consumption & Sustainability', + icon: 'shopping-cart', + }, + { + name: 'Cooperation & Development', + icon: 'users', + id: 'cat8', + }, + ] + beforeEach(() => { + mocks = { + $t: jest.fn(), + } + }) + + describe('shallowMount', () => { + const Wrapper = () => { + return mount(CategoriesSelect, { mocks, localVue }) + } + + beforeEach(() => { + wrapper = Wrapper() + }) + + describe('toggleCategory', () => { + beforeEach(() => { + wrapper.vm.categories = categories + democracyAndPolitics = wrapper.findAll('button').at(0) + democracyAndPolitics.trigger('click') + }) + + it('adds categories to selectedCategoryIds when clicked', () => { + expect(wrapper.vm.selectedCategoryIds).toEqual([categories[0].id]) + }) + + it('emits an updateCategories event when the selectedCategoryIds changes', () => { + expect(wrapper.emitted().updateCategories[0][0]).toEqual([categories[0].id]) + }) + + it('removes categories when clicked a second time', () => { + democracyAndPolitics.trigger('click') + expect(wrapper.vm.selectedCategoryIds).toEqual([]) + }) + + it('changes the selectedCount when selectedCategoryIds is updated', () => { + expect(wrapper.vm.selectedCount).toEqual(1) + democracyAndPolitics.trigger('click') + expect(wrapper.vm.selectedCount).toEqual(0) + }) + + it('sets a category to active when it has been selected', () => { + expect(wrapper.vm.isActive(categories[0].id)).toEqual(true) + }) + + describe('maximum', () => { + beforeEach(() => { + environmentAndNature = wrapper.findAll('button').at(1) + consumptionAndSustainablity = wrapper.findAll('button').at(2) + environmentAndNature.trigger('click') + consumptionAndSustainablity.trigger('click') + }) + + it('allows three categories to be selected', () => { + expect(wrapper.vm.selectedCategoryIds).toEqual([ + categories[0].id, + categories[1].id, + categories[2].id, + ]) + }) + + it('sets reachedMaximum to true after three', () => { + expect(wrapper.vm.reachedMaximum).toEqual(true) + }) + + it('sets other categories to disabled after three', () => { + expect(wrapper.vm.isDisabled(categories[3].id)).toEqual(true) + }) + }) + }) + }) +}) From 865e2c0ec368d0a00fb7c3bbc2ca2bdcbd2d0b74 Mon Sep 17 00:00:00 2001 From: Matt Rider Date: Fri, 28 Jun 2019 10:17:22 -0300 Subject: [PATCH 06/12] Refactor resolver, rename params - Use one cypher statement Co-authored-by: Tirokk --- backend/src/schema/resolvers/posts.js | 52 +++++++++---------- backend/src/schema/resolvers/posts.spec.js | 9 ++-- backend/src/schema/types/schema.gql | 2 +- .../ContributionForm/ContributionForm.spec.js | 10 ++-- webapp/components/ContributionForm/index.vue | 8 +-- webapp/graphql/PostMutations.js | 9 +++- 6 files changed, 46 insertions(+), 44 deletions(-) diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index a9617d255..a19e813ad 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -1,4 +1,5 @@ import { neo4jgraphql } from 'neo4j-graphql-js' +import uuid from 'uuid/v4' import fileUpload from './fileUpload' export default { @@ -9,36 +10,35 @@ export default { }, CreatePost: async (object, params, context, resolveInfo) => { - const { categories } = params - let post + const { categoryIds } = params + delete params.categoryIds params = await fileUpload(params, { file: 'imageUpload', url: 'image' }) - post = await neo4jgraphql(object, params, context, resolveInfo, false) + params.id = params.id || uuid() + let cypher = `CREATE (post:Post {params}) + WITH post + MATCH (author:User {id: $userId}) + MERGE (post)<-[:WROTE]-(author) + ` + if (categoryIds) { + cypher += `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 } const session = context.driver.session() - await session.run( - 'MATCH (author:User {id: $userId}), (post:Post {id: $postId}) ' + - 'MERGE (post)<-[:WROTE]-(author) ' + - 'RETURN author', - { - userId: context.user.id, - postId: post.id, - }, - ) - if (categories && categories.length) { - await session.run( - `MATCH (post:Post {id: $postId}) - UNWIND $categories AS categoryId - MATCH (category:Category {id: categoryId}) - MERGE (post)-[:CATEGORIZED]->(category) - RETURN category`, - { - categories, - postId: post.id, - }, - ) - } + const transactionRes = await session.run(cypher, variables) + + const [post] = transactionRes.records.map(record => { + return record.get('post') + }) + session.close() - return post + + return post.properties }, }, } diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index 06a342c1c..def99d8bb 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -118,19 +118,16 @@ describe('CreatePost', () => { }), ]) const createPostWithCategoriesMutation = ` - mutation($title: String!, $content: String!, $categories: [ID]) { - CreatePost(title: $title, content: $content, categories: $categories) { + mutation($title: String!, $content: String!, $categoryIds: [ID]) { + CreatePost(title: $title, content: $content, categoryIds: $categoryIds) { id - categories { - id - } } } ` const creatPostWithCategoriesVariables = { title: postTitle, content: postContent, - categories: ['cat9', 'cat4', 'cat15'], + categoryIds: ['cat9', 'cat4', 'cat15'], } const postQueryWithCategories = ` query($id: ID) { diff --git a/backend/src/schema/types/schema.gql b/backend/src/schema/types/schema.gql index 3955da8a2..bc3daad26 100644 --- a/backend/src/schema/types/schema.gql +++ b/backend/src/schema/types/schema.gql @@ -56,7 +56,7 @@ type Mutation { createdAt: String updatedAt: String language: String - categories: [ID] + categoryIds: [ID] contentExcerpt: String ): Post } diff --git a/webapp/components/ContributionForm/ContributionForm.spec.js b/webapp/components/ContributionForm/ContributionForm.spec.js index 0991a3ea2..a282ea085 100644 --- a/webapp/components/ContributionForm/ContributionForm.spec.js +++ b/webapp/components/ContributionForm/ContributionForm.spec.js @@ -106,7 +106,7 @@ describe('ContributionForm.vue', () => { content: postContent, language: 'en', id: null, - categories: null, + categoryIds: null, }, } postTitleInput = wrapper.find('.ds-input') @@ -132,9 +132,9 @@ describe('ContributionForm.vue', () => { }) it('supports adding categories', async () => { - const categories = ['cat12', 'cat15', 'cat37'] - expectedParams.variables.categories = categories - wrapper.find(CategoriesSelect).vm.$emit('updateCategories', categories) + 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)) }) @@ -215,7 +215,7 @@ describe('ContributionForm.vue', () => { content: postContent, language: propsData.contribution.language, id: propsData.contribution.id, - categories: null, + categoryIds: null, }, } postTitleInput = wrapper.find('.ds-input') diff --git a/webapp/components/ContributionForm/index.vue b/webapp/components/ContributionForm/index.vue index 670960df5..0b09eee70 100644 --- a/webapp/components/ContributionForm/index.vue +++ b/webapp/components/ContributionForm/index.vue @@ -68,7 +68,7 @@ export default { content: '', language: null, languageOptions: [], - categories: null, + categoryIds: null, }, formSchema: { title: { required: true, min: 3, max: 64 }, @@ -110,7 +110,7 @@ export default { }, methods: { submit() { - const { title, content, language, categories } = this.form + const { title, content, language, categoryIds } = this.form this.loading = true this.$apollo .mutate({ @@ -120,7 +120,7 @@ export default { title, content, language: language ? language.value : this.$i18n.locale(), - categories, + categoryIds, }, }) .then(res => { @@ -150,7 +150,7 @@ export default { }) }, updateCategories(ids) { - this.form.categories = ids + this.form.categoryIds = ids }, }, apollo: { diff --git a/webapp/graphql/PostMutations.js b/webapp/graphql/PostMutations.js index 4f195eb99..63fccda4a 100644 --- a/webapp/graphql/PostMutations.js +++ b/webapp/graphql/PostMutations.js @@ -3,8 +3,13 @@ import gql from 'graphql-tag' export default () => { return { CreatePost: gql` - mutation($title: String!, $content: String!, $language: String, $categories: [ID]) { - CreatePost(title: $title, content: $content, language: $language, categories: $categories) { + mutation($title: String!, $content: String!, $language: String, $categoryIds: [ID]) { + CreatePost( + title: $title + content: $content + language: $language + categoryIds: $categoryIds + ) { id title slug From e06b05af90d61e1bab2e6aad42f4d4d487e9613e Mon Sep 17 00:00:00 2001 From: Matt Rider Date: Sat, 29 Jun 2019 13:09:48 -0300 Subject: [PATCH 07/12] Remove request for categories from CreatePost --- webapp/graphql/PostMutations.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/webapp/graphql/PostMutations.js b/webapp/graphql/PostMutations.js index 63fccda4a..b944a0980 100644 --- a/webapp/graphql/PostMutations.js +++ b/webapp/graphql/PostMutations.js @@ -16,9 +16,6 @@ export default () => { content contentExcerpt language - categories { - name - } } } `, From f30296137610618bda71ec282eec0295c34799f2 Mon Sep 17 00:00:00 2001 From: Matt Rider Date: Mon, 1 Jul 2019 18:00:55 -0300 Subject: [PATCH 08/12] Get categories working with UpdatePost, refactor --- backend/src/middleware/notifications/spec.js | 13 +- backend/src/middleware/sluggifyMiddleware.js | 4 + backend/src/schema/resolvers/posts.js | 45 ++++- backend/src/schema/resolvers/posts.spec.js | 157 ++++++++++++------ backend/src/schema/types/schema.gql | 18 ++ .../CategoriesSelect/CategoriesSelect.vue | 18 +- .../ContributionForm/ContributionForm.spec.js | 26 ++- .../{index.vue => ContributionForm.vue} | 22 ++- webapp/graphql/PostMutations.js | 9 +- webapp/pages/post/create.vue | 2 +- webapp/pages/post/edit/_id.vue | 2 +- 11 files changed, 237 insertions(+), 79 deletions(-) rename webapp/components/ContributionForm/{index.vue => ContributionForm.vue} (89%) 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 @@