Merge pull request #1596 from Human-Connection/1583-follow-mutation

Improve follow/ufollow mutation
This commit is contained in:
Robert Schäfer 2019-09-19 16:54:35 +02:00 committed by GitHub
commit d830fa4ae4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 240 additions and 192 deletions

View File

@ -1,51 +1,44 @@
import { neode as getNeode } from '../../bootstrap/neo4j'
const neode = getNeode()
export default {
Mutation: {
follow: async (_object, params, context, _resolveInfo) => {
const { id, type } = params
const { id: followedId, type } = params
const { user: currentUser } = context
const session = context.driver.session()
const transactionRes = await session.run(
`MATCH (node {id: $id}), (user:User {id: $userId})
WHERE $type IN labels(node) AND NOT $id = $userId
MERGE (user)-[relation:FOLLOWS]->(node)
RETURN COUNT(relation) > 0 as isFollowed`,
{
id,
type,
userId: context.user.id,
},
)
if (type === 'User' && currentUser.id === followedId) {
return null
}
const [isFollowed] = transactionRes.records.map(record => {
return record.get('isFollowed')
})
session.close()
return isFollowed
const [user, followedNode] = await Promise.all([
neode.find('User', currentUser.id),
neode.find(type, followedId),
])
await user.relateTo(followedNode, 'following')
return followedNode.toJson()
},
unfollow: async (_object, params, context, _resolveInfo) => {
const { id, type } = params
const session = context.driver.session()
const { id: followedId, type } = params
const { user: currentUser } = context
const transactionRes = await session.run(
`MATCH (user:User {id: $userId})-[relation:FOLLOWS]->(node {id: $id})
/*
* Note: Neode doesn't provide an easy method for retrieving or removing relationships.
* It's suggested to use query builder feature (https://github.com/adam-cowley/neode/issues/67)
* However, pure cypher query looks cleaner IMO
*/
await neode.cypher(
`MATCH (user:User {id: $currentUser.id})-[relation:FOLLOWS]->(node {id: $followedId})
WHERE $type IN labels(node)
DELETE relation
RETURN COUNT(relation) > 0 as isFollowed`,
{
id,
type,
userId: context.user.id,
},
{ followedId, type, currentUser },
)
const [isFollowed] = transactionRes.records.map(record => {
return record.get('isFollowed')
})
session.close()
return isFollowed
const followedNode = await neode.find(type, followedId)
return followedNode.toJson()
},
},
}

View File

@ -1,36 +1,93 @@
import { GraphQLClient } from 'graphql-request'
import { createTestClient } from 'apollo-server-testing'
import Factory from '../../seed/factories'
import { host, login } from '../../jest/helpers'
import { getDriver } from '../../bootstrap/neo4j'
import createServer from '../../server'
import { gql } from '../../jest/helpers'
const factory = Factory()
let clientUser1
let headersUser1
const driver = getDriver()
const mutationFollowUser = id => `
mutation {
follow(id: "${id}", type: User)
let query
let mutate
let authenticatedUser
let user1
let user2
let variables
const mutationFollowUser = gql`
mutation($id: ID!, $type: FollowTypeEnum) {
follow(id: $id, type: $type) {
name
followedBy {
id
name
}
followedByCurrentUser
}
}
`
const mutationUnfollowUser = id => `
mutation {
unfollow(id: "${id}", type: User)
const mutationUnfollowUser = gql`
mutation($id: ID!, $type: FollowTypeEnum) {
unfollow(id: $id, type: $type) {
name
followedBy {
id
name
}
followedByCurrentUser
}
}
`
const userQuery = gql`
query($id: ID) {
User(id: $id) {
followedBy {
id
}
followedByCurrentUser
}
}
`
beforeAll(() => {
const { server } = createServer({
context: () => ({
driver,
user: authenticatedUser,
cypherParams: {
currentUserId: authenticatedUser ? authenticatedUser.id : null,
},
}),
})
const testClient = createTestClient(server)
query = testClient.query
mutate = testClient.mutate
})
beforeEach(async () => {
await factory.create('User', {
id: 'u1',
email: 'test@example.org',
password: '1234',
})
await factory.create('User', {
id: 'u2',
email: 'test2@example.org',
password: '1234',
})
user1 = await factory
.create('User', {
id: 'u1',
name: 'user1',
email: 'test@example.org',
password: '1234',
})
.then(user => user.toJson())
user2 = await factory
.create('User', {
id: 'u2',
name: 'user2',
email: 'test2@example.org',
password: '1234',
})
.then(user => user.toJson())
headersUser1 = await login({ email: 'test@example.org', password: '1234' })
clientUser1 = new GraphQLClient(host, { headers: headersUser1 })
authenticatedUser = user1
variables = { id: user2.id, type: 'User' }
})
afterEach(async () => {
@ -40,84 +97,73 @@ afterEach(async () => {
describe('follow', () => {
describe('follow user', () => {
describe('unauthenticated follow', () => {
it('throws authorization error', async () => {
const client = new GraphQLClient(host)
await expect(client.request(mutationFollowUser('u2'))).rejects.toThrow('Not Authorised')
test('throws authorization error', async () => {
authenticatedUser = null
const { errors, data } = await mutate({
mutation: mutationFollowUser,
variables,
})
expect(errors[0]).toHaveProperty('message', 'Not Authorised!')
expect(data).toMatchObject({ follow: null })
})
})
it('I can follow another user', async () => {
const res = await clientUser1.request(mutationFollowUser('u2'))
const expected = {
follow: true,
}
expect(res).toMatchObject(expected)
const { User } = await clientUser1.request(`{
User(id: "u2") {
followedBy { id }
followedByCurrentUser
}
}`)
const expected2 = {
followedBy: [{ id: 'u1' }],
test('I can follow another user', async () => {
const { data: result } = await mutate({
mutation: mutationFollowUser,
variables,
})
const expectedUser = {
name: user2.name,
followedBy: [{ id: user1.id, name: user1.name }],
followedByCurrentUser: true,
}
expect(User[0]).toMatchObject(expected2)
expect(result).toMatchObject({ follow: expectedUser })
})
it('I can`t follow myself', async () => {
const res = await clientUser1.request(mutationFollowUser('u1'))
const expected = {
follow: false,
}
expect(res).toMatchObject(expected)
test('I can`t follow myself', async () => {
variables.id = user1.id
const { data: result } = await mutate({ mutation: mutationFollowUser, variables })
const expectedResult = { follow: null }
expect(result).toMatchObject(expectedResult)
const { User } = await clientUser1.request(`{
User(id: "u1") {
followedBy { id }
followedByCurrentUser
}
}`)
const expected2 = {
const { data } = await query({
query: userQuery,
variables: { id: user1.id },
})
const expectedUser = {
followedBy: [],
followedByCurrentUser: false,
}
expect(User[0]).toMatchObject(expected2)
expect(data).toMatchObject({ User: [expectedUser] })
})
})
describe('unfollow user', () => {
beforeEach(async () => {
variables = {
id: user2.id,
type: 'User',
}
await mutate({ mutation: mutationFollowUser, variables })
})
describe('unauthenticated follow', () => {
it('throws authorization error', async () => {
// follow
await clientUser1.request(mutationFollowUser('u2'))
// unfollow
const client = new GraphQLClient(host)
await expect(client.request(mutationUnfollowUser('u2'))).rejects.toThrow('Not Authorised')
test('throws authorization error', async () => {
authenticatedUser = null
const { errors, data } = await mutate({ mutation: mutationUnfollowUser, variables })
expect(errors[0]).toHaveProperty('message', 'Not Authorised!')
expect(data).toMatchObject({ unfollow: null })
})
})
it('I can unfollow a user', async () => {
// follow
await clientUser1.request(mutationFollowUser('u2'))
// unfollow
const expected = {
unfollow: true,
}
const res = await clientUser1.request(mutationUnfollowUser('u2'))
expect(res).toMatchObject(expected)
const { User } = await clientUser1.request(`{
User(id: "u2") {
followedBy { id }
followedByCurrentUser
}
}`)
const expected2 = {
const { data: result } = await mutate({ mutation: mutationUnfollowUser, variables })
const expectedUser = {
name: user2.name,
followedBy: [],
followedByCurrentUser: false,
}
expect(User[0]).toMatchObject(expected2)
expect(result).toMatchObject({ unfollow: expectedUser })
})
})
})

View File

@ -32,9 +32,9 @@ type Mutation {
# Unshout the given Type and ID
unshout(id: ID!, type: ShoutTypeEnum): Boolean!
# Follow the given Type and ID
follow(id: ID!, type: FollowTypeEnum): Boolean!
follow(id: ID!, type: FollowTypeEnum): User
# Unfollow the given Type and ID
unfollow(id: ID!, type: FollowTypeEnum): Boolean!
unfollow(id: ID!, type: FollowTypeEnum): User
}
type Report {

View File

@ -15,7 +15,7 @@
</template>
<script>
import gql from 'graphql-tag'
import { followUserMutation, unfollowUserMutation } from '~/graphql/User'
export default {
name: 'HcFollowButton',
@ -61,26 +61,23 @@ export default {
},
async toggle() {
const follow = !this.isFollowed
const mutation = follow ? 'follow' : 'unfollow'
const mutation = follow ? followUserMutation(this.$i18n) : unfollowUserMutation(this.$i18n)
this.hovered = false
this.$emit('optimistic', follow)
const optimisticResult = { followedByCurrentUser: follow }
this.$emit('optimistic', optimisticResult)
try {
await this.$apollo.mutate({
mutation: gql`
mutation($id: ID!) {
${mutation}(id: $id, type: User)
}
`,
variables: {
id: this.followId,
},
const { data } = await this.$apollo.mutate({
mutation,
variables: { id: this.followId },
})
this.$emit('update', follow)
const followedUser = follow ? data.follow : data.unfollow
this.$emit('update', followedUser)
} catch {
this.$emit('optimistic', !follow)
optimisticResult.followedByCurrentUser = !follow
this.$emit('optimistic', optimisticResult)
}
},
},

View File

@ -139,13 +139,14 @@ export default {
},
},
methods: {
optimisticFollow(follow) {
const inc = follow ? 1 : -1
this.user.followedByCurrentUser = follow
optimisticFollow({ followedByCurrentUser }) {
const inc = followedByCurrentUser ? 1 : -1
this.user.followedByCurrentUser = followedByCurrentUser
this.user.followedByCount += inc
},
updateFollow(follow) {
this.user.followedByCurrentUser = follow
updateFollow({ followedByCurrentUser, followedByCount }) {
this.user.followedByCount = followedByCount
this.user.followedByCurrentUser = followedByCurrentUser
},
},
}

View File

@ -1,72 +1,27 @@
import gql from 'graphql-tag'
import { postFragment, commentFragment } from './Fragments'
import { userFragment, postFragment, commentFragment } from './Fragments'
export default i18n => {
const lang = i18n.locale().toUpperCase()
return gql`
${userFragment(lang)}
query User($id: ID!) {
User(id: $id) {
id
slug
name
avatar
...user
about
disabled
deleted
locationName
location {
name: name${lang}
}
createdAt
badges {
id
icon
}
badgesCount
shoutedCount
commentedCount
contributionsCount
followingCount
following(first: 7) {
id
slug
name
avatar
disabled
deleted
followedByCount
followedByCurrentUser
contributionsCount
commentedCount
badges {
id
icon
}
location {
name: name${lang}
}
...user
}
followedByCount
followedByCurrentUser
isBlocked
followedBy(first: 7) {
id
slug
name
disabled
deleted
avatar
followedByCount
followedByCurrentUser
contributionsCount
commentedCount
badges {
id
icon
}
location {
name: name${lang}
}
followedBy(first: 7) {
...user
}
socialMedia {
id
@ -145,3 +100,37 @@ export const markAsReadMutation = i18n => {
}
`
}
export const followUserMutation = i18n => {
const lang = i18n.locale().toUpperCase()
return gql`
${userFragment(lang)}
mutation($id: ID!) {
follow(id: $id, type: User) {
name
followedByCount
followedByCurrentUser
followedBy(first: 7) {
...user
}
}
}
`
}
export const unfollowUserMutation = i18n => {
const lang = i18n.locale().toUpperCase()
return gql`
${userFragment(lang)}
mutation($id: ID!) {
unfollow(id: $id, type: User) {
name
followedByCount
followedByCurrentUser
followedBy(first: 7) {
...user
}
}
}
`
}

View File

@ -48,7 +48,11 @@
<ds-flex-item>
<client-only>
<ds-number :label="$t('profile.followers')">
<hc-count-to slot="count" :end-val="user.followedByCount" />
<hc-count-to
slot="count"
:start-val="followedByCountStartValue"
:end-val="user.followedByCount"
/>
</ds-number>
</client-only>
</ds-flex-item>
@ -66,8 +70,8 @@
v-if="!user.isBlocked"
:follow-id="user.id"
:is-followed="user.followedByCurrentUser"
@optimistic="follow => (user.followedByCurrentUser = follow)"
@update="follow => fetchUser()"
@optimistic="optimisticFollow"
@update="updateFollow"
/>
<ds-button v-else fullwidth @click="unblock(user)">
{{ $t('settings.blocked-users.unblock') }}
@ -317,6 +321,7 @@ export default {
pageSize: 6,
tabActive: 'post',
filter,
followedByCountStartValue: 0,
}
},
computed: {
@ -367,10 +372,6 @@ export default {
uniq(items, field = 'id') {
return uniqBy(items, field)
},
fetchUser() {
// TODO: we should use subscriptions instead of fetching the whole user again
this.$apollo.queries.User.refetch()
},
showMoreContributions() {
const { Post: PostQuery } = this.$apollo.queries
if (!PostQuery) return // seems this can be undefined on subpages
@ -411,6 +412,27 @@ export default {
this.resetPostList()
this.$apollo.queries.Post.refetch()
},
optimisticFollow({ followedByCurrentUser }) {
/*
* Note: followedByCountStartValue is updated to avoid counting from 0 when follow/unfollow
*/
this.followedByCountStartValue = this.user.followedByCount
const currentUser = this.$store.getters['auth/user']
if (followedByCurrentUser) {
this.user.followedByCount++
this.user.followedBy = [currentUser, ...this.user.followedBy]
} else {
this.user.followedByCount--
this.user.followedBy = this.user.followedBy.filter(user => user.id !== currentUser.id)
}
this.user.followedByCurrentUser = followedByCurrentUser
},
updateFollow({ followedByCurrentUser, followedBy, followedByCount }) {
this.followedByCountStartValue = this.user.followedByCount
this.user.followedByCount = followedByCount
this.user.followedByCurrentUser = followedByCurrentUser
this.user.followedBy = followedBy
},
},
apollo: {
Post: {