Merge pull request #762 from Human-Connection/refactor_filter_bubble

Refactor filter bubble
This commit is contained in:
mattwr18 2019-06-06 14:18:00 -03:00 committed by GitHub
commit bc8dd22a91
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 67 additions and 253 deletions

View File

@ -1,12 +0,0 @@
import replaceParams from './replaceParams'
const replaceFilterBubbleParams = async (resolve, root, args, context, resolveInfo) => {
args = await replaceParams(args, context)
return resolve(root, args, context, resolveInfo)
}
export default {
Query: {
Post: replaceFilterBubbleParams,
},
}

View File

@ -5,6 +5,7 @@ import Factory from '../../seed/factories'
const factory = Factory()
const currentUserParams = {
id: 'u1',
email: 'you@example.org',
name: 'This is you',
password: '1234',
@ -41,7 +42,7 @@ afterEach(async () => {
await factory.cleanDatabase()
})
describe('FilterBubble middleware', () => {
describe('Filter posts by author is followed by sb.', () => {
describe('given an authenticated user', () => {
let authenticatedClient
@ -52,7 +53,7 @@ describe('FilterBubble middleware', () => {
describe('no filter bubble', () => {
it('returns all posts', async () => {
const query = '{ Post( filterBubble: {}) { title } }'
const query = '{ Post(filter: { }) { title } }'
const expected = {
Post: [
{ title: 'This is some random post' },
@ -65,7 +66,7 @@ describe('FilterBubble middleware', () => {
describe('filtering for posts of followed users only', () => {
it('returns only posts authored by followed users', async () => {
const query = '{ Post( filterBubble: { author: following }) { title } }'
const query = '{ Post( filter: { author: { followedBy_some: { id: "u1" } } }) { title } }'
const expected = {
Post: [{ title: 'This is the post of a followed user' }],
}

View File

@ -1,31 +0,0 @@
import { UserInputError } from 'apollo-server'
export default async function replaceParams(args, context) {
const { author = 'all' } = args.filterBubble || {}
const { user } = context
if (author === 'following') {
if (!user)
throw new UserInputError(
"You are unauthenticated - I don't know any users you are following.",
)
const session = context.driver.session()
let { records } = await session.run(
'MATCH(followed:User)<-[:FOLLOWS]-(u {id: $userId}) RETURN followed.id',
{ userId: context.user.id },
)
const followedIds = records.map(record => record.get('followed.id'))
// carefully override `id_in`
args.filter = args.filter || {}
args.filter.author = args.filter.author || {}
args.filter.author.id_in = followedIds
session.close()
}
delete args.filterBubble
return args
}

View File

@ -1,129 +0,0 @@
import replaceParams from './replaceParams.js'
describe('replaceParams', () => {
let args
let context
let run
let action = () => {
return replaceParams(args, context)
}
beforeEach(() => {
args = {}
run = jest.fn().mockResolvedValue({
records: [{ get: () => 1 }, { get: () => 2 }, { get: () => 3 }],
})
context = {
driver: {
session: () => {
return {
run,
close: () => {},
}
},
},
}
})
describe('args == ', () => {
describe('{}', () => {
it('does not crash', async () => {
await expect(action()).resolves.toEqual({})
})
})
describe('unauthenticated user', () => {
beforeEach(() => {
context.user = null
})
describe('{ filterBubble: { author: following } }', () => {
it('throws error', async () => {
args = { filterBubble: { author: 'following' } }
await expect(action()).rejects.toThrow('You are unauthenticated')
})
})
describe('{ filterBubble: { author: all } }', () => {
it('removes filterBubble param', async () => {
const expected = {}
await expect(action()).resolves.toEqual(expected)
})
it('does not make database calls', async () => {
await action()
expect(run).not.toHaveBeenCalled()
})
})
})
describe('authenticated user', () => {
beforeEach(() => {
context.user = { id: 'u4711' }
})
describe('{ filterBubble: { author: following } }', () => {
beforeEach(() => {
args = { filterBubble: { author: 'following' } }
})
it('returns args object with resolved ids of followed users', async () => {
const expected = { filter: { author: { id_in: [1, 2, 3] } } }
await expect(action()).resolves.toEqual(expected)
})
it('makes database calls', async () => {
await action()
expect(run).toHaveBeenCalledTimes(1)
})
describe('given any additional filter args', () => {
describe('merges', () => {
it('empty filter object', async () => {
args.filter = {}
const expected = { filter: { author: { id_in: [1, 2, 3] } } }
await expect(action()).resolves.toEqual(expected)
})
it('filter.title', async () => {
args.filter = { title: 'bla' }
const expected = { filter: { title: 'bla', author: { id_in: [1, 2, 3] } } }
await expect(action()).resolves.toEqual(expected)
})
it('filter.author', async () => {
args.filter = { author: { name: 'bla' } }
const expected = { filter: { author: { name: 'bla', id_in: [1, 2, 3] } } }
await expect(action()).resolves.toEqual(expected)
})
})
})
})
describe('{ filterBubble: { } }', () => {
it('removes filterBubble param', async () => {
const expected = {}
await expect(action()).resolves.toEqual(expected)
})
it('does not make database calls', async () => {
await action()
expect(run).not.toHaveBeenCalled()
})
})
describe('{ filterBubble: { author: all } }', () => {
it('removes filterBubble param', async () => {
const expected = {}
await expect(action()).resolves.toEqual(expected)
})
it('does not make database calls', async () => {
await action()
expect(run).not.toHaveBeenCalled()
})
})
})
})
})

View File

@ -13,7 +13,6 @@ import includedFields from './includedFieldsMiddleware'
import orderBy from './orderByMiddleware'
import validation from './validation'
import notifications from './notifications'
import filterBubble from './filterBubble/filterBubble'
export default schema => {
const middlewares = {
@ -31,13 +30,11 @@ export default schema => {
user: user,
includedFields: includedFields,
orderBy: orderBy,
filterBubble: filterBubble,
}
let order = [
'permissions',
'activityPub',
'filterBubble',
'password',
'dateTime',
'validation',

View File

@ -1,40 +1,3 @@
enum FilterBubbleAuthorEnum {
following
all
}
input FilterBubble {
author: FilterBubbleAuthorEnum
}
type Query {
Post(
id: ID
activityId: String
objectId: String
title: String
slug: String
content: String
contentExcerpt: String
image: String
imageUpload: Upload
visibility: Visibility
deleted: Boolean
disabled: Boolean
createdAt: String
updatedAt: String
commentsCount: Int
shoutedCount: Int
shoutedByCurrentUser: Boolean
_id: String
first: Int
offset: Int
orderBy: [_PostOrdering]
filter: _PostFilter
filterBubble: FilterBubble
): [Post]
}
type Post {
id: ID!
activityId: String

View File

@ -9,9 +9,11 @@ localVue.use(Styleguide)
describe('FilterMenu.vue', () => {
let wrapper
let mocks
let propsData
const createWrapper = mountMethod => {
return mountMethod(FilterMenu, {
propsData,
mocks,
localVue,
})
@ -19,35 +21,48 @@ describe('FilterMenu.vue', () => {
beforeEach(() => {
mocks = { $t: () => {} }
propsData = {}
})
describe('mount', () => {
describe('given a user', () => {
beforeEach(() => {
wrapper = createWrapper(mount)
propsData = {
user: {
id: '4711',
},
}
})
it('renders a card', () => {
expect(wrapper.is('.ds-card')).toBe(true)
})
describe('click "filter-by-followed-authors-only" button', () => {
it('emits filterBubble object', () => {
wrapper.find({ name: 'filter-by-followed-authors-only' }).trigger('click')
expect(wrapper.emitted('changeFilterBubble')).toBeTruthy()
describe('mount', () => {
beforeEach(() => {
wrapper = createWrapper(mount)
})
it('toggles filterBubble.author property', () => {
wrapper.find({ name: 'filter-by-followed-authors-only' }).trigger('click')
expect(wrapper.emitted('changeFilterBubble')[0]).toEqual([{ author: 'following' }])
wrapper.find({ name: 'filter-by-followed-authors-only' }).trigger('click')
expect(wrapper.emitted('changeFilterBubble')[1]).toEqual([{ author: 'all' }])
it('renders a card', () => {
expect(wrapper.is('.ds-card')).toBe(true)
})
it('makes button primary', () => {
wrapper.find({ name: 'filter-by-followed-authors-only' }).trigger('click')
expect(
wrapper.find({ name: 'filter-by-followed-authors-only' }).classes('ds-button-primary'),
).toBe(true)
describe('click "filter-by-followed-authors-only" button', () => {
it('emits filterBubble object', () => {
wrapper.find({ name: 'filter-by-followed-authors-only' }).trigger('click')
expect(wrapper.emitted('changeFilterBubble')).toBeTruthy()
})
it('toggles filterBubble.author property', () => {
wrapper.find({ name: 'filter-by-followed-authors-only' }).trigger('click')
expect(wrapper.emitted('changeFilterBubble')[0]).toEqual([
{ author: { followedBy_some: { id: '4711' } } },
])
wrapper.find({ name: 'filter-by-followed-authors-only' }).trigger('click')
expect(wrapper.emitted('changeFilterBubble')[1]).toEqual([{}])
})
it('makes button primary', () => {
wrapper.find({ name: 'filter-by-followed-authors-only' }).trigger('click')
expect(
wrapper.find({ name: 'filter-by-followed-authors-only' }).classes('ds-button-primary'),
).toBe(true)
})
})
})
})

View File

@ -11,7 +11,7 @@
<ds-button
name="filter-by-followed-authors-only"
icon="user-plus"
:primary="onlyFollowed"
:primary="!!filterAuthorIsFollowedById"
@click="toggleOnlyFollowed"
/>
</div>
@ -22,24 +22,30 @@
<script>
export default {
props: {
user: { type: Object, required: true },
},
data() {
// We have to fix styleguide here. It uses .includes wich will always be
// false for arrays of objects.
return {
filterBubble: {
author: 'all',
},
filter: {},
}
},
computed: {
onlyFollowed() {
return this.filterBubble.author === 'following'
filterAuthorIsFollowedById() {
const { author = {} } = this.filter
/* eslint-disable camelcase */
const { followedBy_some = {} } = author
const { id } = followedBy_some
/* eslint-enable */
return id
},
},
methods: {
toggleOnlyFollowed() {
this.filterBubble.author = this.onlyFollowed ? 'all' : 'following'
this.$emit('changeFilterBubble', this.filterBubble)
this.filter = this.filterAuthorIsFollowedById
? {}
: { author: { followedBy_some: { id: this.user.id } } }
this.$emit('changeFilterBubble', this.filter)
},
},
}

View File

@ -2,7 +2,7 @@
<div>
<ds-flex :width="{ base: '100%' }" gutter="base">
<ds-flex-item>
<filter-menu @changeFilterBubble="changeFilterBubble" />
<filter-menu :user="currentUser" @changeFilterBubble="changeFilterBubble" />
</ds-flex-item>
<hc-post-card
v-for="(post, index) in uniq(Post)"
@ -32,6 +32,7 @@ import gql from 'graphql-tag'
import uniqBy from 'lodash/uniqBy'
import HcPostCard from '~/components/PostCard'
import HcLoadMore from '~/components/LoadMore.vue'
import { mapGetters } from 'vuex'
export default {
components: {
@ -45,10 +46,13 @@ export default {
Post: [],
page: 1,
pageSize: 10,
filterBubble: { author: 'all' },
filter: {},
}
},
computed: {
...mapGetters({
currentUser: 'auth/user',
}),
tags() {
return this.Post ? this.Post[0].tags.map(tag => tag.name) : '-'
},
@ -57,8 +61,8 @@ export default {
},
},
methods: {
changeFilterBubble(filterBubble) {
this.filterBubble = filterBubble
changeFilterBubble(filter) {
this.filter = filter
this.$apollo.queries.Post.refresh()
},
uniq(items, field = 'id') {
@ -76,7 +80,7 @@ export default {
this.page++
this.$apollo.queries.Post.fetchMore({
variables: {
filterBubble: this.filterBubble,
filter: this.filter,
first: this.pageSize,
offset: this.offset,
},
@ -102,8 +106,8 @@ export default {
Post: {
query() {
return gql(`
query Post($filterBubble: FilterBubble, $first: Int, $offset: Int) {
Post(filterBubble: $filterBubble, first: $first, offset: $offset) {
query Post($filter: _PostFilter, $first: Int, $offset: Int) {
Post(filter: $filter, first: $first, offset: $offset) {
id
title
contentExcerpt
@ -146,7 +150,7 @@ export default {
},
variables() {
return {
filterBubble: this.filterBubble,
filter: this.filter,
first: this.pageSize,
offset: 0,
}