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',