diff --git a/.vscode/settings.json b/.vscode/settings.json
index 908252f41..e2a727871 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -9,4 +9,4 @@
],
"editor.formatOnSave": true,
"eslint.autoFixOnSave": true
-}
\ No newline at end of file
+}
diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js
index 9e2ec70a2..3bff53ddb 100644
--- a/backend/src/schema/resolvers/posts.spec.js
+++ b/backend/src/schema/resolvers/posts.spec.js
@@ -74,6 +74,22 @@ describe('CreatePost', () => {
await expect(client.request(mutation)).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") {
+ language
+ }
+ }
+ `
+ const expected = { CreatePost: { language: 'en' } }
+ await expect(client.request(createPostWithLanguageMutation)).resolves.toEqual(
+ expect.objectContaining(expected),
+ )
+ })
+ })
})
})
diff --git a/backend/src/schema/types/type/Post.gql b/backend/src/schema/types/type/Post.gql
index c402a1233..271d92750 100644
--- a/backend/src/schema/types/type/Post.gql
+++ b/backend/src/schema/types/type/Post.gql
@@ -15,29 +15,37 @@ type Post {
disabledBy: User @relation(name: "DISABLED", direction: "IN")
createdAt: String
updatedAt: String
-
- relatedContributions: [Post]! @cypher(
- statement: """
+ language: String
+ relatedContributions: [Post]!
+ @cypher(
+ statement: """
MATCH (this)-[:TAGGED|CATEGORIZED]->(categoryOrTag)<-[:TAGGED|CATEGORIZED]-(post:Post)
RETURN DISTINCT post
LIMIT 10
- """
- )
+ """
+ )
tags: [Tag]! @relation(name: "TAGGED", direction: "OUT")
categories: [Category]! @relation(name: "CATEGORIZED", direction: "OUT")
comments: [Comment]! @relation(name: "COMMENTS", direction: "IN")
- commentsCount: Int! @cypher(statement: "MATCH (this)<-[:COMMENTS]-(r:Comment) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(r)")
+ commentsCount: Int!
+ @cypher(
+ statement: "MATCH (this)<-[:COMMENTS]-(r:Comment) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(r)"
+ )
shoutedBy: [User]! @relation(name: "SHOUTED", direction: "IN")
- shoutedCount: Int! @cypher(statement: "MATCH (this)<-[:SHOUTED]-(r:User) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)")
+ shoutedCount: Int!
+ @cypher(
+ statement: "MATCH (this)<-[:SHOUTED]-(r:User) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)"
+ )
# Has the currently logged in user shouted that post?
- shoutedByCurrentUser: Boolean! @cypher(
- statement: """
+ shoutedByCurrentUser: Boolean!
+ @cypher(
+ statement: """
MATCH (this)<-[:SHOUTED]-(u:User {id: $cypherParams.currentUserId})
RETURN COUNT(u) >= 1
- """
- )
+ """
+ )
}
diff --git a/webapp/components/ContributionForm/ContributionForm.spec.js b/webapp/components/ContributionForm/ContributionForm.spec.js
new file mode 100644
index 000000000..f7f306fc3
--- /dev/null
+++ b/webapp/components/ContributionForm/ContributionForm.spec.js
@@ -0,0 +1,142 @@
+import { config, mount, createLocalVue } from '@vue/test-utils'
+import ContributionForm from './index.vue'
+import Styleguide from '@human-connection/styleguide'
+
+const localVue = createLocalVue()
+
+localVue.use(Styleguide)
+
+config.stubs['no-ssr'] = ''
+
+describe('ContributionForm.vue', () => {
+ let wrapper
+ let postTitleInput
+ let expectedParams
+ let deutschOption
+ let cancelBtn
+ let mocks
+ const postTitle = 'this is a title for a post'
+ const postContent = 'this is a post'
+ const computed = { locale: () => 'English' }
+
+ beforeEach(() => {
+ mocks = {
+ $t: jest.fn(),
+ $apollo: {
+ mutate: jest
+ .fn()
+ .mockResolvedValueOnce({
+ data: {
+ CreatePost: {
+ title: postTitle,
+ slug: 'this-is-a-title-for-a-post',
+ content: postContent,
+ contentExcerpt: postContent,
+ language: 'en',
+ },
+ },
+ })
+ .mockRejectedValue({ message: 'Not Authorised!' }),
+ },
+ $toast: {
+ error: jest.fn(),
+ success: jest.fn(),
+ },
+ $i18n: {
+ locale: () => 'en',
+ },
+ $router: {
+ back: jest.fn(),
+ push: jest.fn(),
+ },
+ }
+ })
+
+ describe('mount', () => {
+ const Wrapper = () => {
+ return mount(ContributionForm, { mocks, localVue, computed })
+ }
+
+ beforeEach(() => {
+ wrapper = Wrapper()
+ wrapper.setData({ form: { languageOptions: [{ label: 'Deutsch', value: 'de' }] } })
+ })
+
+ describe('CreatePost', () => {
+ describe('invalid form submission', () => {
+ it('title required for form submission', async () => {
+ postTitleInput = wrapper.find('.ds-input')
+ postTitleInput.setValue('this is a title for a post')
+ await wrapper.find('form').trigger('submit')
+ expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
+ })
+
+ it('content required for form submission', async () => {
+ wrapper.vm.updateEditorContent('this is a post')
+ await wrapper.find('form').trigger('submit')
+ expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('valid form submission', () => {
+ expectedParams = {
+ variables: { title: postTitle, content: postContent, language: 'en', id: null },
+ }
+ beforeEach(async () => {
+ postTitleInput = wrapper.find('.ds-input')
+ postTitleInput.setValue('this is a title for a post')
+ wrapper.vm.updateEditorContent('this is a post')
+ await wrapper.find('form').trigger('submit')
+ })
+
+ it('with title and content', () => {
+ expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
+ })
+
+ it("sends a fallback language based on a user's locale", () => {
+ expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams))
+ })
+
+ it('supports changing the language', async () => {
+ expectedParams.variables.language = 'de'
+ deutschOption = wrapper.findAll('li').at(0)
+ deutschOption.trigger('click')
+ 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)
+ })
+
+ it('shows a success toaster', () => {
+ expect(mocks.$toast.success).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('cancel', () => {
+ it('calls $router.back() when cancel button clicked', () => {
+ cancelBtn = wrapper.find('.cancel-button')
+ cancelBtn.trigger('click')
+ expect(mocks.$router.back).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('handles errors', () => {
+ beforeEach(async () => {
+ wrapper = Wrapper()
+ postTitleInput = wrapper.find('.ds-input')
+ postTitleInput.setValue('this is a title for a post')
+ wrapper.vm.updateEditorContent('this is a post')
+ // second submission causes mutation to reject
+ await wrapper.find('form').trigger('submit')
+ })
+ it('shows an error toaster when apollo mutation rejects', async () => {
+ await wrapper.find('form').trigger('submit')
+ await mocks.$apollo.mutate
+ expect(mocks.$toast.error).toHaveBeenCalledWith('Not Authorised!')
+ })
+ })
+ })
+ })
+})
diff --git a/webapp/components/ContributionForm/index.vue b/webapp/components/ContributionForm/index.vue
index d22612e3a..203c0963f 100644
--- a/webapp/components/ContributionForm/index.vue
+++ b/webapp/components/ContributionForm/index.vue
@@ -6,8 +6,27 @@
+
+
-
+
{{ $t('actions.cancel') }}
import gql from 'graphql-tag'
import HcEditor from '~/components/Editor'
+import orderBy from 'lodash/orderBy'
+import locales from '~/locales'
import PostMutations from '~/graphql/PostMutations.js'
export default {
@@ -42,6 +63,9 @@ export default {
form: {
title: '',
content: '',
+ language: null,
+ languageOptions: [],
+ placeholder: '',
},
formSchema: {
title: { required: true, min: 3, max: 64 },
@@ -65,13 +89,25 @@ export default {
this.slug = contribution.slug
this.form.content = contribution.content
this.form.title = contribution.title
+ this.form.language = this.locale
+ this.form.placeholder = this.locale
},
},
},
+ computed: {
+ locale() {
+ const locale = this.contribution.language
+ ? locales.find(loc => this.contribution.language === loc.code)
+ : locales.find(loc => this.$i18n.locale() === loc.code)
+ return locale.name
+ },
+ },
+ mounted() {
+ this.availableLocales()
+ },
methods: {
submit() {
this.loading = true
-
this.$apollo
.mutate({
mutation: this.id ? PostMutations().UpdatePost : PostMutations().CreatePost,
@@ -79,11 +115,12 @@ export default {
id: this.id,
title: this.form.title,
content: this.form.content,
+ language: this.form.language ? this.form.language.value : this.$i18n.locale(),
},
})
.then(res => {
this.loading = false
- this.$toast.success('Saved!')
+ this.$toast.success(this.$t('contribution.success'))
this.disabled = true
const result = res.data[this.id ? 'UpdatePost' : 'CreatePost']
@@ -103,6 +140,11 @@ export default {
// this.form.content = value
this.$refs.contributionForm.update('content', value)
},
+ availableLocales() {
+ orderBy(locales, 'name').map(locale => {
+ this.form.languageOptions.push({ label: locale.name, value: locale.code })
+ })
+ },
},
apollo: {
User: {
@@ -135,4 +177,8 @@ export default {
padding-right: 0;
}
}
+
+.contribution-form-footer {
+ border-top: $border-size-base solid $border-color-softest;
+}
diff --git a/webapp/graphql/PostMutations.js b/webapp/graphql/PostMutations.js
index 963e44f6e..e648e5c7b 100644
--- a/webapp/graphql/PostMutations.js
+++ b/webapp/graphql/PostMutations.js
@@ -2,25 +2,27 @@ import gql from 'graphql-tag'
export default () => {
return {
- CreatePost: gql`
- mutation($title: String!, $content: String!) {
- CreatePost(title: $title, content: $content) {
+ CreatePost: gql(`
+ mutation($title: String!, $content: String!, $language: String) {
+ CreatePost(title: $title, content: $content, language: $language) {
id
title
slug
content
contentExcerpt
+ language
}
}
- `,
- UpdatePost: gql`
- mutation($id: ID!, $title: String!, $content: String!) {
- UpdatePost(id: $id, title: $title, content: $content) {
+ `),
+ UpdatePost: gql(`
+ mutation($id: ID!, $title: String!, $content: String!, $language: String) {
+ UpdatePost(id: $id, title: $title, content: $content, language: $language) {
id
title
slug
content
contentExcerpt
+ language
}
}
`,
diff --git a/webapp/locales/de.json b/webapp/locales/de.json
index f6e29ecd3..efe05a472 100644
--- a/webapp/locales/de.json
+++ b/webapp/locales/de.json
@@ -301,5 +301,9 @@
"avatar": {
"submitted": "Upload erfolgreich"
}
+ },
+ "contribution": {
+ "success": "Gespeichert!",
+ "languageSelectLabel": "Sprache"
}
}
diff --git a/webapp/locales/en.json b/webapp/locales/en.json
index fc087b0e6..4fdcadedb 100644
--- a/webapp/locales/en.json
+++ b/webapp/locales/en.json
@@ -300,5 +300,9 @@
"avatar": {
"submitted": "Upload successful"
}
+ },
+ "contribution": {
+ "success": "Saved!",
+ "languageSelectLabel": "Language"
}
}
diff --git a/webapp/pages/post/edit/_id.vue b/webapp/pages/post/edit/_id.vue
index 7dcff5036..150087ce2 100644
--- a/webapp/pages/post/edit/_id.vue
+++ b/webapp/pages/post/edit/_id.vue
@@ -3,9 +3,7 @@
-
-
-
+
@@ -49,6 +47,7 @@ export default {
deleted
slug
image
+ language
author {
id
disabled
diff --git a/webapp/pages/profile/_id/_slug.spec.js b/webapp/pages/profile/_id/_slug.spec.js
index 3c5a1b063..bd6c9c598 100644
--- a/webapp/pages/profile/_id/_slug.spec.js
+++ b/webapp/pages/profile/_id/_slug.spec.js
@@ -26,8 +26,8 @@ describe('ProfileSlug', () => {
id: 'p23',
name: 'It is a post',
},
- $t: jest.fn(t => t),
- // If you mocking router, than don't use VueRouter with localVue: https://vue-test-utils.vuejs.org/guides/using-with-vue-router.html
+ $t: jest.fn(),
+ // If you're mocking router, then don't use VueRouter with localVue: https://vue-test-utils.vuejs.org/guides/using-with-vue-router.html
$route: {
params: {
id: '4711',