Merge branch 'master' of https://github.com/Human-Connection/Human-Connection into 734-authorization-problem-disabling-post

# Conflicts:
#	webapp/components/ContributionForm/index.vue
#	webapp/graphql/PostMutations.js
This commit is contained in:
Wolfgang Huß 2019-06-14 19:02:33 +02:00
commit 68dcbacaff
10 changed files with 248 additions and 27 deletions

View File

@ -74,6 +74,22 @@ describe('CreatePost', () => {
await expect(client.request(mutation)).resolves.toMatchObject(expected) 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),
)
})
})
}) })
}) })

View File

@ -15,29 +15,37 @@ type Post {
disabledBy: User @relation(name: "DISABLED", direction: "IN") disabledBy: User @relation(name: "DISABLED", direction: "IN")
createdAt: String createdAt: String
updatedAt: String updatedAt: String
language: String
relatedContributions: [Post]! @cypher( relatedContributions: [Post]!
statement: """ @cypher(
statement: """
MATCH (this)-[:TAGGED|CATEGORIZED]->(categoryOrTag)<-[:TAGGED|CATEGORIZED]-(post:Post) MATCH (this)-[:TAGGED|CATEGORIZED]->(categoryOrTag)<-[:TAGGED|CATEGORIZED]-(post:Post)
RETURN DISTINCT post RETURN DISTINCT post
LIMIT 10 LIMIT 10
""" """
) )
tags: [Tag]! @relation(name: "TAGGED", direction: "OUT") tags: [Tag]! @relation(name: "TAGGED", direction: "OUT")
categories: [Category]! @relation(name: "CATEGORIZED", direction: "OUT") categories: [Category]! @relation(name: "CATEGORIZED", direction: "OUT")
comments: [Comment]! @relation(name: "COMMENTS", direction: "IN") 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") 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? # Has the currently logged in user shouted that post?
shoutedByCurrentUser: Boolean! @cypher( shoutedByCurrentUser: Boolean!
statement: """ @cypher(
statement: """
MATCH (this)<-[:SHOUTED]-(u:User {id: $cypherParams.currentUserId}) MATCH (this)<-[:SHOUTED]-(u:User {id: $cypherParams.currentUserId})
RETURN COUNT(u) >= 1 RETURN COUNT(u) >= 1
""" """
) )
} }

View File

@ -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'] = '<span><slot /></span>'
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!')
})
})
})
})
})

View File

@ -6,8 +6,27 @@
<no-ssr> <no-ssr>
<hc-editor :users="users" :value="form.content" @input="updateEditorContent" /> <hc-editor :users="users" :value="form.content" @input="updateEditorContent" />
</no-ssr> </no-ssr>
<ds-space margin-bottom="xxx-large" />
<ds-flex class="contribution-form-footer">
<ds-flex-item :width="{ base: '10%', sm: '10%', md: '10%', lg: '15%' }" />
<ds-flex-item :width="{ base: '80%', sm: '30%', md: '30%', lg: '20%' }">
<ds-space margin-bottom="small" />
<ds-select
model="language"
:options="form.languageOptions"
icon="globe"
:placeholder="form.placeholder"
:label="$t('contribution.languageSelectLabel')"
/>
</ds-flex-item>
</ds-flex>
<div slot="footer" style="text-align: right"> <div slot="footer" style="text-align: right">
<ds-button :disabled="loading || disabled" ghost @click.prevent="$router.back()"> <ds-button
:disabled="loading || disabled"
ghost
class="cancel-button"
@click="$router.back()"
>
{{ $t('actions.cancel') }} {{ $t('actions.cancel') }}
</ds-button> </ds-button>
<ds-button <ds-button
@ -28,6 +47,8 @@
<script> <script>
import gql from 'graphql-tag' import gql from 'graphql-tag'
import HcEditor from '~/components/Editor' import HcEditor from '~/components/Editor'
import orderBy from 'lodash/orderBy'
import locales from '~/locales'
import PostMutations from '~/graphql/PostMutations.js' import PostMutations from '~/graphql/PostMutations.js'
export default { export default {
@ -42,6 +63,9 @@ export default {
form: { form: {
title: '', title: '',
content: '', content: '',
language: null,
languageOptions: [],
placeholder: '',
}, },
formSchema: { formSchema: {
title: { required: true, min: 3, max: 64 }, title: { required: true, min: 3, max: 64 },
@ -65,13 +89,25 @@ export default {
this.slug = contribution.slug this.slug = contribution.slug
this.form.content = contribution.content this.form.content = contribution.content
this.form.title = contribution.title 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: { methods: {
submit() { submit() {
this.loading = true this.loading = true
this.$apollo this.$apollo
.mutate({ .mutate({
mutation: this.id ? PostMutations().UpdatePost : PostMutations().CreatePost, mutation: this.id ? PostMutations().UpdatePost : PostMutations().CreatePost,
@ -79,11 +115,12 @@ export default {
id: this.id, id: this.id,
title: this.form.title, title: this.form.title,
content: this.form.content, content: this.form.content,
language: this.form.language ? this.form.language.value : this.$i18n.locale(),
}, },
}) })
.then(res => { .then(res => {
this.loading = false this.loading = false
this.$toast.success('Saved!') this.$toast.success(this.$t('contribution.success'))
this.disabled = true this.disabled = true
const result = res.data[this.id ? 'UpdatePost' : 'CreatePost'] const result = res.data[this.id ? 'UpdatePost' : 'CreatePost']
@ -103,6 +140,11 @@ export default {
// this.form.content = value // this.form.content = value
this.$refs.contributionForm.update('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: { apollo: {
User: { User: {
@ -135,4 +177,8 @@ export default {
padding-right: 0; padding-right: 0;
} }
} }
.contribution-form-footer {
border-top: $border-size-base solid $border-color-softest;
}
</style> </style>

View File

@ -2,25 +2,27 @@ import gql from 'graphql-tag'
export default () => { export default () => {
return { return {
CreatePost: gql` CreatePost: gql(`
mutation($title: String!, $content: String!) { mutation($title: String!, $content: String!, $language: String) {
CreatePost(title: $title, content: $content) { CreatePost(title: $title, content: $content, language: $language) {
id id
title title
slug slug
content content
contentExcerpt contentExcerpt
language
} }
} }
`, `),
UpdatePost: gql` UpdatePost: gql(`
mutation($id: ID!, $title: String!, $content: String!) { mutation($id: ID!, $title: String!, $content: String!, $language: String) {
UpdatePost(id: $id, title: $title, content: $content) { UpdatePost(id: $id, title: $title, content: $content, language: $language) {
id id
title title
slug slug
content content
contentExcerpt contentExcerpt
language
} }
} }
`, `,

View File

@ -301,5 +301,9 @@
"avatar": { "avatar": {
"submitted": "Upload erfolgreich" "submitted": "Upload erfolgreich"
} }
},
"contribution": {
"success": "Gespeichert!",
"languageSelectLabel": "Sprache"
} }
} }

View File

@ -300,5 +300,9 @@
"avatar": { "avatar": {
"submitted": "Upload successful" "submitted": "Upload successful"
} }
},
"contribution": {
"success": "Saved!",
"languageSelectLabel": "Language"
} }
} }

View File

@ -3,9 +3,7 @@
<ds-flex-item :width="{ base: '100%', md: 3 }"> <ds-flex-item :width="{ base: '100%', md: 3 }">
<hc-contribution-form :contribution="contribution" /> <hc-contribution-form :contribution="contribution" />
</ds-flex-item> </ds-flex-item>
<ds-flex-item :width="{ base: '100%', md: 1 }"> <ds-flex-item :width="{ base: '100%', md: 1 }">&nbsp;</ds-flex-item>
&nbsp;
</ds-flex-item>
</ds-flex> </ds-flex>
</template> </template>
@ -49,6 +47,7 @@ export default {
deleted deleted
slug slug
image image
language
author { author {
id id
disabled disabled

View File

@ -26,8 +26,8 @@ describe('ProfileSlug', () => {
id: 'p23', id: 'p23',
name: 'It is a post', name: 'It is a post',
}, },
$t: jest.fn(t => t), $t: jest.fn(),
// If you mocking router, than don't use VueRouter with localVue: https://vue-test-utils.vuejs.org/guides/using-with-vue-router.html // 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: { $route: {
params: { params: {
id: '4711', id: '4711',