Merge pull request #609 from Human-Connection/553-delete-comment

Delete Comment

I’ll come back to your notices and recommendations @roschaefer …
But merge for now.
This commit is contained in:
Wolfgang Huß 2019-06-03 17:20:23 +02:00 committed by GitHub
commit deaf1b4307
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 862 additions and 247 deletions

View File

@ -16,11 +16,15 @@ const isAdmin = rule()(async (parent, args, { user }, info) => {
return user && user.role === 'admin'
})
const isMyOwn = rule({ cache: 'no_cache' })(async (parent, args, context, info) => {
const isMyOwn = rule({
cache: 'no_cache',
})(async (parent, args, context, info) => {
return context.user.id === parent.id
})
const belongsToMe = rule({ cache: 'no_cache' })(async (_, args, context) => {
const belongsToMe = rule({
cache: 'no_cache',
})(async (_, args, context) => {
const {
driver,
user: { id: userId },
@ -32,7 +36,10 @@ const belongsToMe = rule({ cache: 'no_cache' })(async (_, args, context) => {
MATCH (u:User {id: $userId})<-[:NOTIFIED]-(n:Notification {id: $notificationId})
RETURN n
`,
{ userId, notificationId },
{
userId,
notificationId,
},
)
const [notification] = result.records.map(record => {
return record.get('n')
@ -41,12 +48,16 @@ const belongsToMe = rule({ cache: 'no_cache' })(async (_, args, context) => {
return Boolean(notification)
})
const onlyEnabledContent = rule({ cache: 'strict' })(async (parent, args, ctx, info) => {
const onlyEnabledContent = rule({
cache: 'strict',
})(async (parent, args, ctx, info) => {
const { disabled, deleted } = args
return !(disabled || deleted)
})
const isAuthor = rule({ cache: 'no_cache' })(async (parent, args, { user, driver }) => {
const isAuthor = rule({
cache: 'no_cache',
})(async (parent, args, { user, driver }) => {
if (!user) return false
const session = driver.session()
const { id: postId } = args
@ -55,7 +66,9 @@ const isAuthor = rule({ cache: 'no_cache' })(async (parent, args, { user, driver
MATCH (post:Post {id: $postId})<-[:WROTE]-(author)
RETURN author
`,
{ postId },
{
postId,
},
)
const [author] = result.records.map(record => {
return record.get('author')
@ -100,6 +113,7 @@ const permissions = shield({
enable: isModerator,
disable: isModerator,
CreateComment: isAuthenticated,
DeleteComment: isAuthenticated,
// CreateUser: allow,
},
User: {

View File

@ -53,6 +53,11 @@ export default {
)
session.close()
return comment
},
DeleteComment: async (object, params, context, resolveInfo) => {
const comment = await neo4jgraphql(object, params, context, resolveInfo, false)
return comment
},
},

View File

@ -1,3 +1,4 @@
import gql from 'graphql-tag'
import Factory from '../seed/factories'
import { GraphQLClient } from 'graphql-request'
import { host, login } from '../jest/helpers'
@ -5,6 +6,7 @@ import { host, login } from '../jest/helpers'
const factory = Factory()
let client
let createCommentVariables
let deleteCommentVariables
let createPostVariables
let createCommentVariablesSansPostId
let createCommentVariablesWithNonExistentPost
@ -21,22 +23,22 @@ afterEach(async () => {
})
describe('CreateComment', () => {
const createCommentMutation = `
mutation($postId: ID, $content: String!) {
CreateComment(postId: $postId, content: $content) {
id
content
const createCommentMutation = gql`
mutation($postId: ID, $content: String!) {
CreateComment(postId: $postId, content: $content) {
id
content
}
}
}
`
const createPostMutation = `
mutation($id: ID!, $title: String!, $content: String!) {
CreatePost(id: $id, title: $title, content: $content) {
id
const createPostMutation = gql`
mutation($id: ID!, $title: String!, $content: String!) {
CreatePost(id: $id, title: $title, content: $content) {
id
}
}
}
`
const commentQueryForPostId = `
const commentQueryForPostId = gql`
query($content: String) {
Comment(content: $content) {
postId
@ -59,8 +61,13 @@ describe('CreateComment', () => {
describe('authenticated', () => {
let headers
beforeEach(async () => {
headers = await login({ email: 'test@example.org', password: '1234' })
client = new GraphQLClient(host, { headers })
headers = await login({
email: 'test@example.org',
password: '1234',
})
client = new GraphQLClient(host, {
headers,
})
createCommentVariables = {
postId: 'p1',
content: "I'm authorised to comment",
@ -88,15 +95,25 @@ describe('CreateComment', () => {
it('assigns the authenticated user as author', async () => {
await client.request(createCommentMutation, createCommentVariables)
const { User } = await client.request(`{
const { User } = await client.request(gql`
{
User(email: "test@example.org") {
comments {
content
}
}
}`)
}
`)
expect(User).toEqual([{ comments: [{ content: "I'm authorised to comment" }] }])
expect(User).toEqual([
{
comments: [
{
content: "I'm authorised to comment",
},
],
},
])
})
it('throw an error if an empty string is sent from the editor as content', async () => {
@ -186,7 +203,92 @@ describe('CreateComment', () => {
commentQueryForPostId,
commentQueryVariablesByContent,
)
expect(Comment).toEqual([{ postId: null }])
expect(Comment).toEqual([
{
postId: null,
},
])
})
})
})
describe('DeleteComment', () => {
const createCommentMutation = gql`
mutation($postId: ID, $content: String!) {
CreateComment(postId: $postId, content: $content) {
id
content
}
}
`
const deleteCommentMutation = gql`
mutation($id: ID!) {
DeleteComment(id: $id) {
id
}
}
`
const createPostMutation = gql`
mutation($id: ID!, $title: String!, $content: String!) {
CreatePost(id: $id, title: $title, content: $content) {
id
}
}
`
describe('unauthenticated', () => {
it('throws authorization error', async () => {
deleteCommentVariables = {
id: 'c1',
}
client = new GraphQLClient(host)
await expect(client.request(deleteCommentMutation, deleteCommentVariables)).rejects.toThrow(
'Not Authorised',
)
})
})
describe('authenticated', () => {
let headers
beforeEach(async () => {
headers = await login({
email: 'test@example.org',
password: '1234',
})
client = new GraphQLClient(host, {
headers,
})
createCommentVariables = {
id: 'c1',
postId: 'p1',
content: "I'm authorised to comment",
}
deleteCommentVariables = {
id: 'c1',
}
createPostVariables = {
id: 'p1',
title: 'post to comment on',
content: 'please comment on me',
}
await client.request(createPostMutation, createPostVariables)
})
it('deletes the authors comment', async () => {
const { CreateComment } = await client.request(createCommentMutation, createCommentVariables)
deleteCommentVariables = {
id: CreateComment.id,
}
const expected = {
DeleteComment: {
id: CreateComment.id,
},
}
await expect(
client.request(deleteCommentMutation, deleteCommentVariables),
).resolves.toMatchObject(expected)
})
it.todo('throws an error if it tries to delete a comment not from this author')
})
})

View File

@ -1,13 +1,14 @@
import gql from 'graphql-tag'
import Factory from '../seed/factories'
import { GraphQLClient } from 'graphql-request'
import { host, login } from '../jest/helpers'
const factory = Factory()
describe('CreateSocialMedia', () => {
describe('SocialMedia', () => {
let client
let headers
const mutationC = `
const mutationC = gql`
mutation($url: String!) {
CreateSocialMedia(url: $url) {
id
@ -15,7 +16,7 @@ describe('CreateSocialMedia', () => {
}
}
`
const mutationD = `
const mutationD = gql`
mutation($id: ID!) {
DeleteSocialMedia(id: $id) {
id
@ -42,19 +43,28 @@ describe('CreateSocialMedia', () => {
describe('unauthenticated', () => {
it('throws authorization error', async () => {
client = new GraphQLClient(host)
const variables = { url: 'http://nsosp.org' }
const variables = {
url: 'http://nsosp.org',
}
await expect(client.request(mutationC, variables)).rejects.toThrow('Not Authorised')
})
})
describe('authenticated', () => {
beforeEach(async () => {
headers = await login({ email: 'test@example.org', password: '1234' })
client = new GraphQLClient(host, { headers })
headers = await login({
email: 'test@example.org',
password: '1234',
})
client = new GraphQLClient(host, {
headers,
})
})
it('creates social media with correct URL', async () => {
const variables = { url: 'http://nsosp.org' }
const variables = {
url: 'http://nsosp.org',
}
await expect(client.request(mutationC, variables)).resolves.toEqual(
expect.objectContaining({
CreateSocialMedia: {
@ -66,11 +76,15 @@ describe('CreateSocialMedia', () => {
})
it('deletes social media', async () => {
const creationVariables = { url: 'http://nsosp.org' }
const creationVariables = {
url: 'http://nsosp.org',
}
const { CreateSocialMedia } = await client.request(mutationC, creationVariables)
const { id } = CreateSocialMedia
const deletionVariables = { id }
const deletionVariables = {
id,
}
const expected = {
DeleteSocialMedia: {
id: id,
@ -81,12 +95,16 @@ describe('CreateSocialMedia', () => {
})
it('rejects empty string', async () => {
const variables = { url: '' }
const variables = {
url: '',
}
await expect(client.request(mutationC, variables)).rejects.toThrow('Input is not a URL')
})
it('validates URLs', async () => {
const variables = { url: 'not-a-url' }
const variables = {
url: 'not-a-url',
}
await expect(client.request(mutationC, variables)).rejects.toThrow('Input is not a URL')
})
})

View File

@ -14,11 +14,20 @@ describe('Comment.vue', () => {
let propsData
let mocks
let getters
let wrapper
let Wrapper
beforeEach(() => {
propsData = {}
mocks = {
$t: jest.fn(),
$toast: {
success: jest.fn(),
error: jest.fn(),
},
$apollo: {
mutate: jest.fn().mockResolvedValue(),
},
}
getters = {
'auth/user': () => {
@ -29,11 +38,16 @@ describe('Comment.vue', () => {
})
describe('shallowMount', () => {
const Wrapper = () => {
Wrapper = () => {
const store = new Vuex.Store({
getters,
})
return shallowMount(Comment, { store, propsData, mocks, localVue })
return shallowMount(Comment, {
store,
propsData,
mocks,
localVue,
})
}
describe('given a comment', () => {
@ -45,7 +59,7 @@ describe('Comment.vue', () => {
})
it('renders content', () => {
const wrapper = Wrapper()
wrapper = Wrapper()
expect(wrapper.text()).toMatch('Hello I am a comment content')
})
@ -55,17 +69,17 @@ describe('Comment.vue', () => {
})
it('renders no comment data', () => {
const wrapper = Wrapper()
wrapper = Wrapper()
expect(wrapper.text()).not.toMatch('comment content')
})
it('has no "disabled-content" css class', () => {
const wrapper = Wrapper()
wrapper = Wrapper()
expect(wrapper.classes()).not.toContain('disabled-content')
})
it('translates a placeholder', () => {
/* const wrapper = */ Wrapper()
wrapper = Wrapper()
const calls = mocks.$t.mock.calls
const expected = [['comment.content.unavailable-placeholder']]
expect(calls).toEqual(expect.arrayContaining(expected))
@ -77,16 +91,46 @@ describe('Comment.vue', () => {
})
it('renders comment data', () => {
const wrapper = Wrapper()
wrapper = Wrapper()
expect(wrapper.text()).toMatch('comment content')
})
it('has a "disabled-content" css class', () => {
const wrapper = Wrapper()
wrapper = Wrapper()
expect(wrapper.classes()).toContain('disabled-content')
})
})
})
beforeEach(jest.useFakeTimers)
describe('test callbacks', () => {
beforeEach(() => {
wrapper = Wrapper()
})
describe('deletion of Comment from List by invoking "deleteCommentCallback()"', () => {
beforeEach(() => {
wrapper.vm.deleteCommentCallback()
})
describe('after timeout', () => {
beforeEach(jest.runAllTimers)
it('emits "deleteComment"', () => {
expect(wrapper.emitted().deleteComment.length).toBe(1)
})
it('does call mutation', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
})
it('mutation is successful', () => {
expect(mocks.$toast.success).toHaveBeenCalledTimes(1)
})
})
})
})
})
})
})

View File

@ -14,6 +14,7 @@
placement="bottom-end"
resource-type="comment"
:resource="comment"
:callbacks="{ confirm: deleteCommentCallback, cancel: null }"
style="float-right"
:is-owner="isAuthor(author.id)"
/>
@ -27,6 +28,7 @@
</template>
<script>
import gql from 'graphql-tag'
import { mapGetters } from 'vuex'
import HcUser from '~/components/User'
import ContentMenu from '~/components/ContentMenu'
@ -61,6 +63,25 @@ export default {
isAuthor(id) {
return this.user.id === id
},
async deleteCommentCallback() {
try {
var gqlMutation = gql`
mutation($id: ID!) {
DeleteComment(id: $id) {
id
}
}
`
await this.$apollo.mutate({
mutation: gqlMutation,
variables: { id: this.comment.id },
})
this.$toast.success(this.$t(`delete.comment.success`))
this.$emit('deleteComment')
} catch (err) {
this.$toast.error(err.message)
}
},
},
}
</script>

View File

@ -28,6 +28,7 @@
import Dropdown from '~/components/Dropdown'
export default {
name: 'ContentMenu',
components: {
Dropdown,
},
@ -42,6 +43,7 @@ export default {
return value.match(/(contribution|comment|organization|user)/)
},
},
callbacks: { type: Object, required: true },
},
computed: {
routes() {
@ -49,7 +51,7 @@ export default {
if (this.isOwner && this.resourceType === 'contribution') {
routes.push({
name: this.$t(`contribution.edit`),
name: this.$t(`post.menu.edit`),
path: this.$router.resolve({
name: 'post-edit-id',
params: {
@ -59,21 +61,29 @@ export default {
icon: 'edit',
})
routes.push({
name: this.$t(`post.delete.title`),
name: this.$t(`post.menu.delete`),
callback: () => {
this.openModal('delete')
},
icon: 'trash',
})
}
if (this.isOwner && this.resourceType === 'comment') {
// routes.push({
// name: this.$t(`comment.menu.edit`),
// callback: () => {
// /* eslint-disable-next-line no-console */
// console.log('EDIT COMMENT')
// },
// icon: 'edit'
// })
routes.push({
name: this.$t(`comment.edit`),
name: this.$t(`comment.menu.delete`),
callback: () => {
/* eslint-disable-next-line no-console */
console.log('EDIT COMMENT')
this.openModal('delete')
},
icon: 'edit',
icon: 'trash',
})
}
@ -125,6 +135,7 @@ export default {
data: {
type: this.resourceType,
resource: this.resource,
callbacks: this.callbacks,
},
})
},

View File

@ -1,5 +1,6 @@
import { shallowMount, createLocalVue } from '@vue/test-utils'
import Modal from './Modal.vue'
import DeleteModal from './Modal/DeleteModal.vue'
import DisableModal from './Modal/DisableModal.vue'
import ReportModal from './Modal/ReportModal.vue'
import Vuex from 'vuex'
@ -29,7 +30,11 @@ describe('Modal.vue', () => {
'modal/SET_OPEN': mutations.SET_OPEN,
},
})
return mountMethod(Modal, { store, mocks, localVue })
return mountMethod(Modal, {
store,
mocks,
localVue,
})
}
}
@ -55,6 +60,7 @@ describe('Modal.vue', () => {
it('initially empty', () => {
wrapper = Wrapper()
expect(wrapper.contains(DeleteModal)).toBe(false)
expect(wrapper.contains(DisableModal)).toBe(false)
expect(wrapper.contains(ReportModal)).toBe(false)
})
@ -69,6 +75,10 @@ describe('Modal.vue', () => {
id: 'c456',
title: 'some title',
},
callbacks: {
confirm: null,
cancel: null,
},
},
}
wrapper = Wrapper()
@ -83,6 +93,10 @@ describe('Modal.vue', () => {
type: 'contribution',
name: 'some title',
id: 'c456',
callbacks: {
confirm: null,
cancel: null,
},
})
})
@ -97,23 +111,49 @@ describe('Modal.vue', () => {
it('passes author name to disable modal', () => {
state.data = {
type: 'comment',
resource: { id: 'c456', author: { name: 'Author name' } },
resource: {
id: 'c456',
author: {
name: 'Author name',
},
},
callbacks: {
confirm: null,
cancel: null,
},
}
wrapper = Wrapper()
expect(wrapper.find(DisableModal).props()).toEqual({
type: 'comment',
name: 'Author name',
id: 'c456',
callbacks: {
confirm: null,
cancel: null,
},
})
})
it('does not crash if author is undefined', () => {
state.data = { type: 'comment', resource: { id: 'c456' } }
state.data = {
type: 'comment',
resource: {
id: 'c456',
},
callbacks: {
confirm: null,
cancel: null,
},
}
wrapper = Wrapper()
expect(wrapper.find(DisableModal).props()).toEqual({
type: 'comment',
name: '',
id: 'c456',
callbacks: {
confirm: null,
cancel: null,
},
})
})
})

View File

@ -1,10 +1,12 @@
<template>
<div class="modal-wrapper">
<!-- Todo: Put all modals with 2 buttons and equal properties in one customiced 'danger-action-modal' -->
<disable-modal
v-if="open === 'disable'"
:id="data.resource.id"
:type="data.type"
:name="name"
:callbacks="data.callbacks"
@close="close"
/>
<report-modal
@ -12,6 +14,7 @@
:id="data.resource.id"
:type="data.type"
:name="name"
:callbacks="data.callbacks"
@close="close"
/>
<delete-modal
@ -19,15 +22,16 @@
:id="data.resource.id"
:type="data.type"
:name="name"
:callbacks="data.callbacks"
@close="close"
/>
</div>
</template>
<script>
import DeleteModal from '~/components/Modal/DeleteModal'
import DisableModal from '~/components/Modal/DisableModal'
import ReportModal from '~/components/Modal/ReportModal'
import DeleteModal from '~/components/Modal/DeleteModal'
import { mapGetters } from 'vuex'
export default {

View File

@ -2,17 +2,14 @@ import { shallowMount, mount, createLocalVue } from '@vue/test-utils'
import DeleteModal from './DeleteModal.vue'
import Vuex from 'vuex'
import Styleguide from '@human-connection/styleguide'
import VueRouter from 'vue-router'
const routes = [{ path: '/' }]
const router = new VueRouter({ routes })
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Styleguide)
localVue.use(VueRouter)
describe('DeleteModal.vue', () => {
let Wrapper
let wrapper
let propsData
let mocks
@ -20,79 +17,113 @@ describe('DeleteModal.vue', () => {
beforeEach(() => {
propsData = {
type: 'contribution',
id: 'c300',
id: 'p23',
name: 'It is a post',
callbacks: {
confirm: jest.fn(),
cancel: jest.fn(),
},
}
mocks = {
$t: jest.fn(),
$filters: {
truncate: a => a,
},
$toast: {
success: () => {},
error: () => {},
},
$apollo: {
mutate: jest.fn().mockResolvedValue(),
},
}
})
describe('shallowMount', () => {
const Wrapper = () => {
return shallowMount(DeleteModal, { propsData, mocks, localVue, router })
Wrapper = () => {
return shallowMount(DeleteModal, {
propsData,
mocks,
localVue,
})
}
describe('defaults', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('success false', () => {
expect(Wrapper().vm.success).toBe(false)
expect(wrapper.vm.success).toBe(false)
})
it('loading false', () => {
expect(Wrapper().vm.loading).toBe(false)
expect(wrapper.vm.loading).toBe(false)
})
})
describe('given a post', () => {
beforeEach(() => {
propsData = {
...propsData,
type: 'contribution',
id: 'p23',
type: 'post',
name: 'It is a post',
}
wrapper = Wrapper()
})
it('mentions post title', () => {
Wrapper()
const calls = mocks.$t.mock.calls
const expected = [['post.delete.message', { name: 'It is a post' }]]
const expected = [
[
'delete.contribution.message',
{
name: 'It is a post',
},
],
]
expect(calls).toEqual(expect.arrayContaining(expected))
})
})
describe('given a comment', () => {
beforeEach(() => {
propsData = {
...propsData,
type: 'comment',
id: 'c4',
name: 'It is the user of the comment',
}
wrapper = Wrapper()
})
it('mentions comments user name', () => {
const calls = mocks.$t.mock.calls
const expected = [
[
'delete.comment.message',
{
name: 'It is the user of the comment',
},
],
]
expect(calls).toEqual(expect.arrayContaining(expected))
})
})
})
describe('mount', () => {
const Wrapper = () => {
return mount(DeleteModal, { propsData, mocks, localVue, router })
Wrapper = () => {
return mount(DeleteModal, {
propsData,
mocks,
localVue,
})
}
beforeEach(jest.useFakeTimers)
it('renders', () => {
expect(Wrapper().is('div')).toBe(true)
})
describe('given id', () => {
describe('given post id', () => {
beforeEach(() => {
propsData = {
type: 'user',
id: 'u3',
}
wrapper = Wrapper()
})
describe('click cancel button', () => {
beforeEach(() => {
wrapper = Wrapper()
wrapper.find('button.cancel').trigger('click')
})
@ -103,12 +134,12 @@ describe('DeleteModal.vue', () => {
expect(wrapper.vm.isOpen).toBe(false)
})
it('emits "close"', () => {
expect(wrapper.emitted().close).toBeTruthy()
it('does call the cancel callback', () => {
expect(propsData.callbacks.cancel).toHaveBeenCalledTimes(1)
})
it('does not call mutation', () => {
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
it('emits "close"', () => {
expect(wrapper.emitted().close).toBeTruthy()
})
})
})
@ -118,20 +149,10 @@ describe('DeleteModal.vue', () => {
wrapper.find('button.confirm').trigger('click')
})
it('calls delete mutation', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalled()
})
it('sets success', () => {
expect(wrapper.vm.success).toBe(true)
})
it('displays a success message', () => {
const calls = mocks.$t.mock.calls
const expected = [['post.delete.success']]
expect(calls).toEqual(expect.arrayContaining(expected))
})
describe('after timeout', () => {
beforeEach(jest.runAllTimers)
@ -139,6 +160,9 @@ describe('DeleteModal.vue', () => {
expect(wrapper.vm.isOpen).toBe(false)
})
it('does call the confirm callback', () => {
expect(propsData.callbacks.confirm).toHaveBeenCalledTimes(1)
})
it('emits close', () => {
expect(wrapper.emitted().close).toBeTruthy()
})

View File

@ -10,19 +10,16 @@
<p v-html="message" />
<template slot="footer">
<ds-button class="cancel" icon="close" @click="cancel">
{{ $t('post.delete.cancel') }}
</ds-button>
<ds-button class="cancel" icon="close" @click="cancel">{{ $t('delete.cancel') }}</ds-button>
<ds-button danger class="confirm" icon="trash" :loading="loading" @click="confirm">
{{ $t('post.delete.submit') }}
{{ $t('delete.submit') }}
</ds-button>
</template>
</ds-modal>
</template>
<script>
import gql from 'graphql-tag'
import { SweetalertIcon } from 'vue-sweetalert-icons'
export default {
@ -33,6 +30,7 @@ export default {
props: {
name: { type: String, default: '' },
type: { type: String, required: true },
callbacks: { type: Object, required: true },
id: { type: String, required: true },
},
data() {
@ -44,15 +42,18 @@ export default {
},
computed: {
title() {
return this.$t(`post.delete.title`)
return this.$t(`delete.${this.type}.title`)
},
message() {
const name = this.$filters.truncate(this.name, 30)
return this.$t(`post.delete.message`, { name })
return this.$t(`delete.${this.type}.message`, { name })
},
},
methods: {
async cancel() {
if (this.callbacks.cancel) {
await this.callbacks.cancel()
}
this.isOpen = false
setTimeout(() => {
this.$emit('close')
@ -61,35 +62,19 @@ export default {
async confirm() {
this.loading = true
try {
await this.$apollo.mutate({
mutation: gql`
mutation($id: ID!) {
DeletePost(id: $id) {
id
}
}
`,
variables: { id: this.id },
})
if (this.callbacks.confirm) {
await this.callbacks.confirm()
}
this.success = true
this.$toast.success(this.$t('post.delete.success'))
setTimeout(() => {
this.isOpen = false
setTimeout(() => {
this.success = false
this.$emit('close')
if (this.$router.history.current.name === 'post-id-slug') {
// redirect to index
this.$router.history.push('/')
} else {
// reload the page (when deleting from profile or index)
window.location.assign(window.location.href)
}
}, 500)
}, 1500)
} catch (err) {
this.success = false
this.$toast.error(err.message)
} finally {
this.loading = false
}

View File

@ -14,8 +14,12 @@ describe('DisableModal.vue', () => {
beforeEach(() => {
propsData = {
type: 'contribution',
name: 'blah',
id: 'c42',
name: 'blah',
callbacks: {
confirm: jest.fn(),
cancel: jest.fn(),
},
}
mocks = {
$filters: {
@ -34,22 +38,34 @@ describe('DisableModal.vue', () => {
describe('shallowMount', () => {
const Wrapper = () => {
return shallowMount(DisableModal, { propsData, mocks, localVue })
return shallowMount(DisableModal, {
propsData,
mocks,
localVue,
})
}
describe('given a user', () => {
beforeEach(() => {
propsData = {
...propsData,
type: 'user',
id: 'u2',
name: 'Bob Ross',
id: 'u2',
}
})
it('mentions user name', () => {
Wrapper()
const calls = mocks.$t.mock.calls
const expected = [['disable.user.message', { name: 'Bob Ross' }]]
const expected = [
[
'disable.user.message',
{
name: 'Bob Ross',
},
],
]
expect(calls).toEqual(expect.arrayContaining(expected))
})
})
@ -57,16 +73,24 @@ describe('DisableModal.vue', () => {
describe('given a contribution', () => {
beforeEach(() => {
propsData = {
...propsData,
type: 'contribution',
id: 'c3',
name: 'This is some post title.',
id: 'c3',
}
})
it('mentions contribution title', () => {
Wrapper()
const calls = mocks.$t.mock.calls
const expected = [['disable.contribution.message', { name: 'This is some post title.' }]]
const expected = [
[
'disable.contribution.message',
{
name: 'This is some post title.',
},
],
]
expect(calls).toEqual(expect.arrayContaining(expected))
})
})
@ -74,13 +98,18 @@ describe('DisableModal.vue', () => {
describe('mount', () => {
const Wrapper = () => {
return mount(DisableModal, { propsData, mocks, localVue })
return mount(DisableModal, {
propsData,
mocks,
localVue,
})
}
beforeEach(jest.useFakeTimers)
describe('given id', () => {
beforeEach(() => {
propsData = {
...propsData,
type: 'user',
id: 'u4711',
}
@ -126,7 +155,9 @@ describe('DisableModal.vue', () => {
it('passes id to mutation', () => {
const calls = mocks.$apollo.mutate.mock.calls
const [[{ variables }]] = calls
expect(variables).toEqual({ id: 'u4711' })
expect(variables).toEqual({
id: 'u4711',
})
})
it('fades away', () => {

View File

@ -19,9 +19,11 @@
import gql from 'graphql-tag'
export default {
name: 'DisableModal',
props: {
name: { type: String, default: '' },
type: { type: String, required: true },
callbacks: { type: Object, required: true },
id: { type: String, required: true },
},
data() {
@ -41,7 +43,10 @@ export default {
},
},
methods: {
cancel() {
async cancel() {
if (this.callbacks.cancel) {
await this.callbacks.cancel()
}
this.isOpen = false
setTimeout(() => {
this.$emit('close')
@ -49,6 +54,9 @@ export default {
},
async confirm() {
try {
if (this.callbacks.confirm) {
await this.callbacks.confirm()
}
await this.$apollo.mutate({
mutation: gql`
mutation($id: ID!) {

View File

@ -17,6 +17,10 @@ describe('ReportModal.vue', () => {
propsData = {
type: 'contribution',
id: 'c43',
callbacks: {
confirm: jest.fn(),
cancel: jest.fn(),
},
}
mocks = {
$t: jest.fn(),
@ -35,7 +39,11 @@ describe('ReportModal.vue', () => {
describe('shallowMount', () => {
const Wrapper = () => {
return shallowMount(ReportModal, { propsData, mocks, localVue })
return shallowMount(ReportModal, {
propsData,
mocks,
localVue,
})
}
describe('defaults', () => {
@ -51,6 +59,7 @@ describe('ReportModal.vue', () => {
describe('given a user', () => {
beforeEach(() => {
propsData = {
...propsData,
type: 'user',
id: 'u4',
name: 'Bob Ross',
@ -60,7 +69,14 @@ describe('ReportModal.vue', () => {
it('mentions user name', () => {
Wrapper()
const calls = mocks.$t.mock.calls
const expected = [['report.user.message', { name: 'Bob Ross' }]]
const expected = [
[
'report.user.message',
{
name: 'Bob Ross',
},
],
]
expect(calls).toEqual(expect.arrayContaining(expected))
})
})
@ -68,8 +84,9 @@ describe('ReportModal.vue', () => {
describe('given a post', () => {
beforeEach(() => {
propsData = {
id: 'p23',
...propsData,
type: 'post',
id: 'p23',
name: 'It is a post',
}
})
@ -77,7 +94,14 @@ describe('ReportModal.vue', () => {
it('mentions post title', () => {
Wrapper()
const calls = mocks.$t.mock.calls
const expected = [['report.post.message', { name: 'It is a post' }]]
const expected = [
[
'report.post.message',
{
name: 'It is a post',
},
],
]
expect(calls).toEqual(expect.arrayContaining(expected))
})
})
@ -85,7 +109,11 @@ describe('ReportModal.vue', () => {
describe('mount', () => {
const Wrapper = () => {
return mount(ReportModal, { propsData, mocks, localVue })
return mount(ReportModal, {
propsData,
mocks,
localVue,
})
}
beforeEach(jest.useFakeTimers)
@ -97,6 +125,7 @@ describe('ReportModal.vue', () => {
describe('given id', () => {
beforeEach(() => {
propsData = {
...propsData,
type: 'user',
id: 'u4711',
}

View File

@ -39,6 +39,7 @@ export default {
props: {
name: { type: String, default: '' },
type: { type: String, required: true },
callbacks: { type: Object, required: true },
id: { type: String, required: true },
},
data() {
@ -59,6 +60,9 @@ export default {
},
methods: {
async cancel() {
if (this.callbacks.cancel) {
await this.callbacks.cancel()
}
this.isOpen = false
setTimeout(() => {
this.$emit('close')
@ -67,6 +71,9 @@ export default {
async confirm() {
this.loading = true
try {
if (this.callbacks.confirm) {
await this.callbacks.confirm()
}
await this.$apollo.mutate({
mutation: gql`
mutation($id: ID!) {

View File

@ -1,61 +1,68 @@
<template>
<ds-card :image="post.image" :class="{ 'post-card': true, 'disabled-content': post.disabled }">
<!-- Post Link Target -->
<nuxt-link
class="post-link"
:to="{ name: 'post-id-slug', params: { id: post.id, slug: post.slug } }"
>
{{ post.title }}
</nuxt-link>
<ds-space margin-bottom="small" />
<!-- Username, Image & Date of Post -->
<div>
<no-ssr>
<hc-user :user="post.author" :trunc="35" :date-time="post.createdAt" />
</no-ssr>
<hc-ribbon :text="$t('post.name')" />
</div>
<ds-space margin-bottom="small" />
<!-- Post Title -->
<ds-heading tag="h3" no-margin>
{{ post.title }}
</ds-heading>
<ds-space margin-bottom="small" />
<!-- Post Content Excerpt -->
<!-- eslint-disable vue/no-v-html -->
<!-- TODO: replace editor content with tiptap render view -->
<div class="hc-editor-content" v-html="excerpt" />
<!-- eslint-enable vue/no-v-html -->
<!-- Footer o the Post -->
<template slot="footer">
<div style="display: inline-block; opacity: .5;">
<!-- Categories -->
<hc-category
v-for="category in post.categories"
:key="category.id"
v-tooltip="{ content: category.name, placement: 'bottom-start', delay: { show: 500 } }"
:icon="category.icon"
/>
<ds-flex-item :width="width">
<ds-card :image="post.image" :class="{ 'post-card': true, 'disabled-content': post.disabled }">
<!-- Post Link Target -->
<nuxt-link
class="post-link"
:to="{ name: 'post-id-slug', params: { id: post.id, slug: post.slug } }"
>
{{ post.title }}
</nuxt-link>
<ds-space margin-bottom="small" />
<!-- Username, Image & Date of Post -->
<div>
<no-ssr>
<hc-user :user="post.author" :trunc="35" :date-time="post.createdAt" />
</no-ssr>
<hc-ribbon :text="$t('post.name')" />
</div>
<no-ssr>
<div style="display: inline-block; float: right">
<!-- Shouts Count -->
<span :style="{ opacity: post.shoutedCount ? 1 : 0.5 }">
<ds-icon name="bullhorn" />
<small>{{ post.shoutedCount }}</small>
</span>
&nbsp;
<!-- Comments Count -->
<span :style="{ opacity: post.commentsCount ? 1 : 0.5 }">
<ds-icon name="comments" />
<small>{{ post.commentsCount }}</small>
</span>
<!-- Menu -->
<content-menu resource-type="contribution" :resource="post" :is-owner="isAuthor" />
<ds-space margin-bottom="small" />
<!-- Post Title -->
<ds-heading tag="h3" no-margin>
{{ post.title }}
</ds-heading>
<ds-space margin-bottom="small" />
<!-- Post Content Excerpt -->
<!-- eslint-disable vue/no-v-html -->
<!-- TODO: replace editor content with tiptap render view -->
<div class="hc-editor-content" v-html="excerpt" />
<!-- eslint-enable vue/no-v-html -->
<!-- Footer o the Post -->
<template slot="footer">
<div style="display: inline-block; opacity: .5;">
<!-- Categories -->
<hc-category
v-for="category in post.categories"
:key="category.id"
v-tooltip="{ content: category.name, placement: 'bottom-start', delay: { show: 500 } }"
:icon="category.icon"
/>
</div>
</no-ssr>
</template>
</ds-card>
<no-ssr>
<div style="display: inline-block; float: right">
<!-- Shouts Count -->
<span :style="{ opacity: post.shoutedCount ? 1 : 0.5 }">
<ds-icon name="bullhorn" />
<small>{{ post.shoutedCount }}</small>
</span>
&nbsp;
<!-- Comments Count -->
<span :style="{ opacity: post.commentsCount ? 1 : 0.5 }">
<ds-icon name="comments" />
<small>{{ post.commentsCount }}</small>
</span>
<!-- Menu -->
<content-menu
resource-type="contribution"
:resource="post"
:callbacks="{ confirm: deletePostCallback, cancel: null }"
:is-owner="isAuthor"
/>
</div>
</no-ssr>
</template>
</ds-card>
</ds-flex-item>
</template>
<script>
@ -65,6 +72,7 @@ import HcCategory from '~/components/Category'
import HcRibbon from '~/components/Ribbon'
// import { randomBytes } from 'crypto'
import { mapGetters } from 'vuex'
import PostMutationHelpers from '~/mixins/PostMutationHelpers'
export default {
name: 'HcPostCard',
@ -74,11 +82,16 @@ export default {
HcRibbon,
ContentMenu,
},
mixins: [PostMutationHelpers],
props: {
post: {
type: Object,
required: true,
},
width: {
type: Object,
default: () => {},
},
},
computed: {
...mapGetters({

View File

@ -17,7 +17,12 @@
</h3>
<ds-space margin-bottom="large" />
<div v-if="comments && comments.length" id="comments" class="comments">
<comment v-for="comment in comments" :key="comment.id" :comment="comment" />
<comment
v-for="(comment, index) in comments"
:key="comment.id"
:comment="comment"
@deleteComment="comments.splice(index, 1)"
/>
</div>
<hc-empty v-else name="empty" icon="messages" />
</div>

View File

@ -135,19 +135,24 @@
"takeAction": {
"name": "Aktiv werden"
},
"delete": {
"submit": "Löschen",
"cancel": "Abbrechen",
"success": "Beitrag erfolgreich gelöscht",
"title": "Beitrag löschen",
"type": "Beitrag",
"message": "Möchtest Du wirklich den Beitrag \"<b>{name}</b>\" löschen?"
"menu": {
"edit": "Beitrag bearbeiten",
"delete": "Beitrag löschen"
},
"comment": {
"submit": "Kommentiere",
"submitted": "Kommentar Gesendet"
}
},
"comment": {
"content": {
"unavailable-placeholder": "...dieser Kommentar ist nicht mehr verfügbar"
},
"menu": {
"edit": "Kommentar bearbeiten",
"delete": "Kommentar löschen"
}
},
"quotes": {
"african": {
"quote": "Viele kleine Leute an vielen kleinen Orten, die viele kleine Dinge tun, werden das Antlitz dieser Welt verändern.",
@ -210,6 +215,22 @@
"message": "Bist du sicher, dass du den Kommentar \"<b>{name}</b>\" deaktivieren möchtest?"
}
},
"delete": {
"submit": "Löschen",
"cancel": "Abbrechen",
"contribution": {
"title": "Lösche Beitrag",
"type": "Contribution",
"message": "Bist du sicher, dass du den Beitrag \"<b>{name}</b>\" löschen möchtest?",
"success": "Beitrag erfolgreich gelöscht!"
},
"comment": {
"title": "Lösche Kommentar",
"type": "Comment",
"message": "Bist du sicher, dass du den Kommentar von \"<b>{name}</b>\" löschen möchtest?",
"success": "Kommentar erfolgreich gelöscht!"
}
},
"report": {
"submit": "Melden",
"cancel": "Abbrechen",
@ -230,17 +251,6 @@
"message": "Bist du sicher, dass du den Kommentar von \"<b>{name}</b>\" melden möchtest?"
}
},
"contribution": {
"edit": "Beitrag bearbeiten",
"delete": "Beitrag löschen"
},
"comment": {
"edit": "Kommentar bearbeiten",
"delete": "Kommentar löschen",
"content": {
"unavailable-placeholder": "...dieser Kommentar ist nicht mehr verfügbar"
}
},
"followButton": {
"follow": "Folgen",
"following": "Folge Ich"

View File

@ -135,19 +135,24 @@
"takeAction": {
"name": "Take action"
},
"delete": {
"submit": "Delete",
"cancel": "Cancel",
"success": "Post deleted successfully",
"title": "Delete Post",
"type": "Contribution",
"message": "Do you really want to delete the post \"<b>{name}</b>\"?"
"menu": {
"edit": "Edit Post",
"delete": "Delete Post"
},
"comment": {
"submit": "Comment",
"submitted": "Comment Submitted"
}
},
"comment": {
"content": {
"unavailable-placeholder": "...this comment is not available anymore"
},
"menu": {
"edit": "Edit Comment",
"delete": "Delete Comment"
}
},
"quotes": {
"african": {
"quote": "Many small people in many small places do many small things, that can alter the face of the world.",
@ -210,6 +215,22 @@
"message": "Do you really want to disable the comment from \"<b>{name}</b>\"?"
}
},
"delete": {
"submit": "Delete",
"cancel": "Cancel",
"contribution": {
"title": "Delete Post",
"type": "Contribution",
"message": "Do you really want to delete the post \"<b>{name}</b>\"?",
"success": "Post successfully deleted!"
},
"comment": {
"title": "Delete Comment",
"type": "Comment",
"message": "Do you really want to delete the comment from \"<b>{name}</b>\"?",
"success": "Comment successfully deleted!"
}
},
"report": {
"submit": "Report",
"cancel": "Cancel",
@ -230,17 +251,6 @@
"message": "Do you really want to report the comment from \"<b>{name}</b>\"?"
}
},
"contribution": {
"edit": "Edit Contribution",
"delete": "Delete Contribution"
},
"comment": {
"edit": "Edit Comment",
"delete": "Delete Comment",
"content": {
"unavailable-placeholder": "...this comment is not available anymore"
}
},
"followButton": {
"follow": "Follow",
"following": "Following"

View File

@ -0,0 +1,39 @@
import gql from 'graphql-tag'
export default {
methods: {
async deletePostCallback(postDisplayType = 'list') {
// console.log('inside "deletePostCallback" !!! ', this.post)
try {
var gqlMutation = gql`
mutation($id: ID!) {
DeletePost(id: $id) {
id
}
}
`
await this.$apollo.mutate({
mutation: gqlMutation,
variables: {
id: this.post.id,
},
})
this.$toast.success(this.$t('delete.contribution.success'))
// console.log('called "this.$t" !!!')
switch (postDisplayType) {
case 'list':
this.$emit('deletePost')
// console.log('emitted "deletePost" !!!')
break
default:
// case 'page'
// console.log('called "this.$router.history.push" !!!')
this.$router.history.push('/') // Single page type: Redirect to index (main) page
break
}
} catch (err) {
this.$toast.error(err.message)
}
},
},
}

View File

@ -29,7 +29,6 @@
"!**/?(*.)+(spec|test).js?(x)"
],
"coverageReporters": [
"text",
"lcov"
],
"transform": {

View File

@ -1,13 +1,13 @@
<template>
<div>
<ds-flex v-if="Post && Post.length" :width="{ base: '100%' }" gutter="base">
<ds-flex-item
v-for="post in uniq(Post)"
<hc-post-card
v-for="(post, index) in uniq(Post)"
:key="post.id"
:post="post"
:width="{ base: '100%', xs: '100%', md: '50%', xl: '33%' }"
>
<hc-post-card :post="post" />
</ds-flex-item>
@deletePost="deletePost(index, post.id)"
/>
</ds-flex>
<no-ssr>
<ds-button
@ -78,6 +78,14 @@ export default {
fetchPolicy: 'cache-and-network',
})
},
deletePost(_index, postId) {
this.Post = this.Post.filter(post => {
return post.id !== postId
})
// Why "uniq(Post)" is used in the array for list creation?
// Ideal solution here:
// this.Post.splice(index, 1)
},
},
apollo: {
Post: {

View File

@ -0,0 +1,100 @@
import { config, shallowMount, createLocalVue } from '@vue/test-utils'
import PostSlug from './index.vue'
import Vuex from 'vuex'
import Styleguide from '@human-connection/styleguide'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Styleguide)
config.stubs['no-ssr'] = '<span><slot /></span>'
describe('PostSlug', () => {
let wrapper
let Wrapper
let store
let mocks
beforeEach(() => {
store = new Vuex.Store({
getters: {
'auth/user': () => {
return {}
},
},
})
mocks = {
$t: jest.fn(),
$filters: {
truncate: a => a,
},
// If you mocking router, than don't use VueRouter with lacalVue: https://vue-test-utils.vuejs.org/guides/using-with-vue-router.html
$router: {
history: {
push: jest.fn(),
},
},
$toast: {
success: jest.fn(),
error: jest.fn(),
},
$apollo: {
mutate: jest.fn().mockResolvedValue(),
},
}
})
describe('shallowMount', () => {
Wrapper = () => {
return shallowMount(PostSlug, {
store,
mocks,
localVue,
})
}
beforeEach(jest.useFakeTimers)
describe('test mixin "PostMutationHelpers"', () => {
beforeEach(() => {
wrapper = Wrapper()
wrapper.setData({
post: {
id: 'p23',
name: 'It is a post',
author: {
id: 'u1',
},
},
})
})
describe('deletion of Post from Page by invoking "deletePostCallback(`page`)"', () => {
beforeEach(() => {
wrapper.vm.deletePostCallback('page')
})
describe('after timeout', () => {
beforeEach(jest.runAllTimers)
it('not emits "deletePost"', () => {
expect(wrapper.emitted().deletePost).toBeFalsy()
})
it('does go to index (main) page', () => {
expect(mocks.$router.history.push).toHaveBeenCalledTimes(1)
})
it('does call mutation', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
})
it('mutation is successful', () => {
expect(mocks.$toast.success).toHaveBeenCalledTimes(1)
})
})
})
})
})
})

View File

@ -12,6 +12,7 @@
placement="bottom-end"
resource-type="contribution"
:resource="post"
:callbacks="{ confirm: () => deletePostCallback('page'), cancel: null }"
:is-owner="isAuthor(post.author.id)"
/>
</no-ssr>
@ -71,8 +72,10 @@ import HcUser from '~/components/User'
import HcShoutButton from '~/components/ShoutButton.vue'
import HcCommentForm from '~/components/comments/CommentForm'
import HcCommentList from '~/components/comments/CommentList'
import PostMutationHelpers from '~/mixins/PostMutationHelpers'
export default {
name: 'PostSlug',
transition: {
name: 'slide-up',
mode: 'out-in',
@ -86,6 +89,7 @@ export default {
HcCommentForm,
HcCommentList,
},
mixins: [PostMutationHelpers],
head() {
return {
title: this.title,

View File

@ -20,7 +20,7 @@
&nbsp;
<!--<ds-tag
v-for="category in post.categories"
:key="category.id"><ds-icon :name="category.icon" /> {{ category.name }}</ds-tag>-->
:key="category.id"><ds-icon :name="category.icon" /> {{ category.name }}</ds-tag>-->
</div>
<template v-if="post.tags && post.tags.length">
<h3>
@ -37,13 +37,13 @@
<h3>Verwandte Beiträge</h3>
<ds-section style="margin: 0 -1.5rem; padding: 1.5rem;">
<ds-flex v-if="post.relatedContributions && post.relatedContributions.length" gutter="small">
<ds-flex-item
v-for="relatedPost in post.relatedContributions"
<hc-post-card
v-for="(relatedPost, index) in post.relatedContributions"
:key="relatedPost.id"
:post="relatedPost"
:width="{ base: '100%', lg: 1 }"
>
<hc-post-card :post="relatedPost" />
</ds-flex-item>
@deletePost="post.relatedContributions.splice(index, 1)"
/>
</ds-flex>
<hc-empty v-else margin="large" icon="file" message="No related Posts" />
</ds-section>

View File

@ -0,0 +1,81 @@
import { shallowMount, createLocalVue } from '@vue/test-utils'
import ProfileSlug from './_slug.vue'
import Vuex from 'vuex'
import Styleguide from '@human-connection/styleguide'
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Styleguide)
describe('ProfileSlug', () => {
let wrapper
let Wrapper
let mocks
beforeEach(() => {
mocks = {
post: {
id: 'p23',
name: 'It is a post',
},
$t: jest.fn(),
// If you mocking router, than don't use VueRouter with lacalVue: https://vue-test-utils.vuejs.org/guides/using-with-vue-router.html
$router: {
history: {
push: jest.fn(),
},
},
$toast: {
success: jest.fn(),
error: jest.fn(),
},
$apollo: {
mutate: jest.fn().mockResolvedValue(),
},
}
})
describe('shallowMount', () => {
Wrapper = () => {
return shallowMount(ProfileSlug, {
mocks,
localVue,
})
}
beforeEach(jest.useFakeTimers)
describe('test mixin "PostMutationHelpers"', () => {
beforeEach(() => {
wrapper = Wrapper()
})
describe('deletion of Post from List by invoking "deletePostCallback(`list`)"', () => {
beforeEach(() => {
wrapper.vm.deletePostCallback('list')
})
describe('after timeout', () => {
beforeEach(jest.runAllTimers)
it('emits "deletePost"', () => {
expect(wrapper.emitted().deletePost.length).toBe(1)
})
it('does not go to index (main) page', () => {
expect(mocks.$router.history.push).not.toHaveBeenCalled()
})
it('does call mutation', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
})
it('mutation is successful', () => {
expect(mocks.$toast.success).toHaveBeenCalledTimes(1)
})
})
})
})
})
})

View File

@ -17,6 +17,7 @@
placement="bottom-end"
resource-type="user"
:resource="user"
:callbacks="{ confirm: deletePostCallback, cancel: null }"
:is-owner="myProfile"
class="user-content-menu"
/>
@ -183,13 +184,13 @@
/>
</ds-flex-item>
<template v-if="activePosts.length">
<ds-flex-item
v-for="post in activePosts"
<hc-post-card
v-for="(post, index) in activePosts"
:key="post.id"
:post="post"
:width="{ base: '100%', md: '100%', xl: '50%' }"
>
<hc-post-card :post="post" />
</ds-flex-item>
@deletePost="user.contributions.splice(index, 1)"
/>
</template>
<template v-else>
<ds-flex-item :width="{ base: '100%' }">
@ -216,6 +217,7 @@ import HcEmpty from '~/components/Empty.vue'
import ContentMenu from '~/components/ContentMenu'
import HcUpload from '~/components/Upload'
import HcAvatar from '~/components/Avatar/Avatar.vue'
import PostMutationHelpers from '~/mixins/PostMutationHelpers'
export default {
components: {
@ -230,6 +232,7 @@ export default {
ContentMenu,
HcUpload,
},
mixins: [PostMutationHelpers],
transition: {
name: 'slide-up',
mode: 'out-in',