diff --git a/backend/package.json b/backend/package.json index b710d1c9b..be5fb086d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -43,7 +43,7 @@ }, "dependencies": { "activitystrea.ms": "~2.1.3", - "apollo-cache-inmemory": "~1.6.0", + "apollo-cache-inmemory": "~1.6.1", "apollo-client": "~2.6.1", "apollo-link-context": "~1.0.14", "apollo-link-http": "~1.5.14", @@ -52,7 +52,7 @@ "cheerio": "~1.0.0-rc.3", "cors": "~2.8.5", "cross-env": "~5.2.0", - "date-fns": "2.0.0-alpha.27", + "date-fns": "2.0.0-alpha.29", "debug": "~4.1.1", "dotenv": "~8.0.0", "express": "~4.17.1", diff --git a/backend/src/middleware/filterBubble/filterBubble.js b/backend/src/middleware/filterBubble/filterBubble.js new file mode 100644 index 000000000..bfdad5e2c --- /dev/null +++ b/backend/src/middleware/filterBubble/filterBubble.js @@ -0,0 +1,12 @@ +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, + }, +} diff --git a/backend/src/middleware/filterBubble/filterBubble.spec.js b/backend/src/middleware/filterBubble/filterBubble.spec.js new file mode 100644 index 000000000..afe1df1c9 --- /dev/null +++ b/backend/src/middleware/filterBubble/filterBubble.spec.js @@ -0,0 +1,76 @@ +import { GraphQLClient } from 'graphql-request' +import { host, login } from '../../jest/helpers' +import Factory from '../../seed/factories' + +const factory = Factory() + +const currentUserParams = { + email: 'you@example.org', + name: 'This is you', + password: '1234', +} +const followedAuthorParams = { + id: 'u2', + email: 'followed@example.org', + name: 'Followed User', + password: '1234', +} +const randomAuthorParams = { + email: 'someone@example.org', + name: 'Someone else', + password: 'else', +} + +beforeEach(async () => { + await Promise.all([ + factory.create('User', currentUserParams), + factory.create('User', followedAuthorParams), + factory.create('User', randomAuthorParams), + ]) + const [asYourself, asFollowedUser, asSomeoneElse] = await Promise.all([ + Factory().authenticateAs(currentUserParams), + Factory().authenticateAs(followedAuthorParams), + Factory().authenticateAs(randomAuthorParams), + ]) + await asYourself.follow({ id: 'u2', type: 'User' }) + await asFollowedUser.create('Post', { title: 'This is the post of a followed user' }) + await asSomeoneElse.create('Post', { title: 'This is some random post' }) +}) + +afterEach(async () => { + await factory.cleanDatabase() +}) + +describe('FilterBubble middleware', () => { + describe('given an authenticated user', () => { + let authenticatedClient + + beforeEach(async () => { + const headers = await login(currentUserParams) + authenticatedClient = new GraphQLClient(host, { headers }) + }) + + describe('no filter bubble', () => { + it('returns all posts', async () => { + const query = '{ Post( filterBubble: {}) { title } }' + const expected = { + Post: [ + { title: 'This is some random post' }, + { title: 'This is the post of a followed user' }, + ], + } + await expect(authenticatedClient.request(query)).resolves.toEqual(expected) + }) + }) + + 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 expected = { + Post: [{ title: 'This is the post of a followed user' }], + } + await expect(authenticatedClient.request(query)).resolves.toEqual(expected) + }) + }) + }) +}) diff --git a/backend/src/middleware/filterBubble/replaceParams.js b/backend/src/middleware/filterBubble/replaceParams.js new file mode 100644 index 000000000..a10b6c29d --- /dev/null +++ b/backend/src/middleware/filterBubble/replaceParams.js @@ -0,0 +1,31 @@ +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 +} diff --git a/backend/src/middleware/filterBubble/replaceParams.spec.js b/backend/src/middleware/filterBubble/replaceParams.spec.js new file mode 100644 index 000000000..e14fda416 --- /dev/null +++ b/backend/src/middleware/filterBubble/replaceParams.spec.js @@ -0,0 +1,129 @@ +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() + }) + }) + }) + }) +}) diff --git a/backend/src/middleware/index.js b/backend/src/middleware/index.js index 75314abc0..6bc7be000 100644 --- a/backend/src/middleware/index.js +++ b/backend/src/middleware/index.js @@ -13,6 +13,7 @@ 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 = { @@ -30,11 +31,13 @@ export default schema => { user: user, includedFields: includedFields, orderBy: orderBy, + filterBubble: filterBubble, } let order = [ 'permissions', 'activityPub', + 'filterBubble', 'password', 'dateTime', 'validation', diff --git a/backend/src/schema/types/type/Post.gql b/backend/src/schema/types/type/Post.gql index 6e23862ed..1179c3e20 100644 --- a/backend/src/schema/types/type/Post.gql +++ b/backend/src/schema/types/type/Post.gql @@ -1,3 +1,40 @@ +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 @@ -40,4 +77,4 @@ type Post { RETURN COUNT(u) >= 1 """ ) -} \ No newline at end of file +} diff --git a/backend/yarn.lock b/backend/yarn.lock index 03a8260ba..9c8fdc3c5 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1296,18 +1296,18 @@ apollo-cache-control@^0.1.0: dependencies: graphql-extensions "^0.0.x" -apollo-cache-inmemory@~1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/apollo-cache-inmemory/-/apollo-cache-inmemory-1.6.0.tgz#a106cdc520f0a043be2575372d5dbb7e4790254c" - integrity sha512-Mr86ucMsXnRH9YRvcuuy6kc3dtyRBuVSo8gdxp2sJVuUAtvQ6r/8E+ok2qX84em9ZBAYxoyvPnKeShhvcKiiDw== +apollo-cache-inmemory@~1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/apollo-cache-inmemory/-/apollo-cache-inmemory-1.6.1.tgz#536b6f366461f6264250041f9146363e2faa1d4c" + integrity sha512-c/WJjh9MTWcdussCTjLKufpPjTx3qOFkBPHIDOOpQ+U0B7K1PczPl9N0LaC4ir3wAWL7s4A0t2EKtoR+6UP92g== dependencies: - apollo-cache "^1.3.0" - apollo-utilities "^1.3.0" + apollo-cache "^1.3.1" + apollo-utilities "^1.3.1" optimism "^0.9.0" ts-invariant "^0.4.0" tslib "^1.9.3" -apollo-cache@1.3.1, apollo-cache@^1.3.0: +apollo-cache@1.3.1, apollo-cache@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/apollo-cache/-/apollo-cache-1.3.1.tgz#c015f93a9a7f32b3eeea0c471addd6e854da754c" integrity sha512-BJ/Mehr3u6XCaHYSmgZ6DM71Fh30OkW6aEr828WjHvs+7i0RUuP51/PM7K6T0jPXtuw7UbArFFPZZsNgXnyyJA== @@ -1559,7 +1559,7 @@ apollo-upload-server@^7.0.0: http-errors "^1.7.0" object-path "^0.11.4" -apollo-utilities@1.3.1, apollo-utilities@^1.0.1, apollo-utilities@^1.2.1, apollo-utilities@^1.3.0, apollo-utilities@^1.3.1: +apollo-utilities@1.3.1, apollo-utilities@^1.0.1, apollo-utilities@^1.2.1, apollo-utilities@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.3.1.tgz#4c45f9b52783c324e2beef822700bdea374f82d1" integrity sha512-P5cJ75rvhm9hcx9V/xCW0vlHhRd0S2icEcYPoRYNTc5djbynpuO+mQuJ4zMHgjNDpvvDxDfZxXTJ6ZUuJZodiQ== @@ -2579,10 +2579,10 @@ data-urls@^1.0.0: whatwg-mimetype "^2.2.0" whatwg-url "^7.0.0" -date-fns@2.0.0-alpha.27: - version "2.0.0-alpha.27" - resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.0-alpha.27.tgz#5ecd4204ef0e7064264039570f6e8afbc014481c" - integrity sha512-cqfVLS+346P/Mpj2RpDrBv0P4p2zZhWWvfY5fuWrXNR/K38HaAGEkeOwb47hIpQP9Jr/TIxjZ2/sNMQwdXuGMg== +date-fns@2.0.0-alpha.29: + version "2.0.0-alpha.29" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.0-alpha.29.tgz#9d4a36e3ebba63d009e957fea8fdfef7921bc6cb" + integrity sha512-AIFZ0hG/1fdb7HZHTDyiEJdNiaFyZxXcx/kF8z3I9wxbhkN678KrrLSneKcsb0Xy5KqCA4wCIxmGpdVWSNZnpA== debug@2.6.9, debug@^2.1.2, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9: version "2.6.9" diff --git a/cypress/integration/common/profile.js b/cypress/integration/common/profile.js index cb5689f63..1df1e2652 100644 --- a/cypress/integration/common/profile.js +++ b/cypress/integration/common/profile.js @@ -12,14 +12,16 @@ Then('I should be able to change my profile picture', () => { cy.fixture(avatarUpload, 'base64').then(fileContent => { cy.get('#customdropzone').upload( { fileContent, fileName: avatarUpload, mimeType: 'image/png' }, - { subjectType: 'drag-n-drop' }, + { subjectType: 'drag-n-drop' } ) }) - cy.get('#customdropzone') - .should('have.attr', 'style') + cy.get('.profile-avatar img') + .should('have.attr', 'src') .and('contains', 'onourjourney') - cy.contains('.iziToast-message', 'Upload successful') - .should('have.length', 1) + cy.contains('.iziToast-message', 'Upload successful').should( + 'have.length', + 1 + ) }) When("I visit another user's profile page", () => { @@ -31,4 +33,4 @@ Then('I cannot upload a picture', () => { .children() .should('not.have.id', 'customdropzone') .should('have.class', 'ds-avatar') -}) \ No newline at end of file +}) diff --git a/cypress/integration/common/steps.js b/cypress/integration/common/steps.js index 387f33ac0..8f5bcc8ea 100644 --- a/cypress/integration/common/steps.js +++ b/cypress/integration/common/steps.js @@ -230,7 +230,7 @@ When('I type in the following text:', text => { Then('the post shows up on the landing page at position {int}', index => { cy.openPage('landing') - const selector = `:nth-child(${index}) > .ds-card > .ds-card-content` + const selector = `.post-card:nth-child(${index}) > .ds-card-content` cy.get(selector).should('contain', lastPost.title) cy.get(selector).should('contain', lastPost.content) }) diff --git a/webapp/components/FilterMenu/FilterMenu.spec.js b/webapp/components/FilterMenu/FilterMenu.spec.js new file mode 100644 index 000000000..c312a401b --- /dev/null +++ b/webapp/components/FilterMenu/FilterMenu.spec.js @@ -0,0 +1,54 @@ +import { mount, createLocalVue } from '@vue/test-utils' +import FilterMenu from './FilterMenu.vue' +import Styleguide from '@human-connection/styleguide' + +const localVue = createLocalVue() + +localVue.use(Styleguide) + +describe('FilterMenu.vue', () => { + let wrapper + let mocks + + const createWrapper = mountMethod => { + return mountMethod(FilterMenu, { + mocks, + localVue, + }) + } + + beforeEach(() => { + mocks = { $t: () => {} } + }) + + describe('mount', () => { + beforeEach(() => { + wrapper = createWrapper(mount) + }) + + 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() + }) + + 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('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) + }) + }) + }) +}) diff --git a/webapp/components/FilterMenu/FilterMenu.vue b/webapp/components/FilterMenu/FilterMenu.vue new file mode 100644 index 000000000..a2195a5fd --- /dev/null +++ b/webapp/components/FilterMenu/FilterMenu.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/webapp/components/Upload/index.vue b/webapp/components/Upload/index.vue index a1ea4ab55..f7f730632 100644 --- a/webapp/components/Upload/index.vue +++ b/webapp/components/Upload/index.vue @@ -4,21 +4,30 @@ id="customdropzone" :key="user.avatar" ref="el" + :use-custom-slot="true" :options="dropzoneOptions" - :include-styling="false" - :style="backgroundImage" - @vdropzone-thumbnail="thumbnail" @vdropzone-error="verror" - /> + > +
+ +
+
+ +
+
+
+ - diff --git a/webapp/components/Upload/spec.js b/webapp/components/Upload/spec.js index b81babb6e..8ee5f6046 100644 --- a/webapp/components/Upload/spec.js +++ b/webapp/components/Upload/spec.js @@ -35,26 +35,6 @@ describe('Upload', () => { }, } - const fileSuccess = { - filename: 'avatar.jpg', - previewElement: { - classList: { - remove: jest.fn(), - add: jest.fn(), - }, - querySelectorAll: jest.fn().mockReturnValue([ - { - alt: '', - style: { - 'background-image': '/api/generic.jpg', - }, - }, - ]), - }, - } - - const dataUrl = 'avatar.jpg' - beforeEach(() => { jest.useFakeTimers() wrapper = shallowMount(Upload, { localVue, propsData, mocks }) @@ -69,11 +49,6 @@ describe('Upload', () => { expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1) }) - it('thumbnail', () => { - wrapper.vm.thumbnail(fileSuccess, dataUrl) - expect(fileSuccess.previewElement.classList.add).toHaveBeenCalledTimes(1) - }) - describe('error handling', () => { const message = 'File upload failed' const fileError = { status: 'error' } @@ -93,5 +68,15 @@ describe('Upload', () => { jest.runAllTimers() expect(wrapper.vm.error).toEqual(false) }) + + it('shows an error toaster when the apollo mutation rejects', async () => { + // calls vddrop twice because of how mockResolvedValueOnce works in jest + // the first time the mock function is called it will resolve, calling it a + // second time will cause it to fail(with this implementation) + // https://jestjs.io/docs/en/mock-function-api.html#mockfnmockresolvedvalueoncevalue + await wrapper.vm.vddrop([{ filename: 'avatar.jpg' }]) + await wrapper.vm.vddrop([{ filename: 'avatar.jpg' }]) + expect(mocks.$toast.error).toHaveBeenCalledTimes(1) + }) }) }) diff --git a/webapp/locales/de.json b/webapp/locales/de.json index 909659366..a790e6461 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -1,4 +1,7 @@ { + "filter-menu": { + "title": "Deine Filterblase" + }, "login": { "copy": "Wenn Du bereits ein Konto bei Human Connection hast, melde Dich bitte hier an.", "login": "Einloggen", diff --git a/webapp/locales/en.json b/webapp/locales/en.json index bd517a7ce..289928f92 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -1,4 +1,7 @@ { + "filter-menu": { + "title": "Your filter bubble" + }, "login": { "copy": "If you already have a human-connection account, login here.", "login": "Login", diff --git a/webapp/package.json b/webapp/package.json index 492cd094d..379053d08 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -51,7 +51,7 @@ "dependencies": { "@human-connection/styleguide": "0.5.17", "@nuxtjs/apollo": "4.0.0-rc4.2", - "@nuxtjs/axios": "~5.5.3", + "@nuxtjs/axios": "~5.5.4", "@nuxtjs/dotenv": "~1.3.0", "@nuxtjs/style-resources": "~0.1.2", "accounting": "~0.4.1", @@ -59,7 +59,7 @@ "apollo-client": "~2.6.1", "cookie-universal-nuxt": "~2.0.14", "cross-env": "~5.2.0", - "date-fns": "2.0.0-alpha.27", + "date-fns": "2.0.0-alpha.29", "express": "~4.17.1", "graphql": "~14.3.1", "jsonwebtoken": "~8.5.1", @@ -70,12 +70,11 @@ "stack-utils": "^1.0.2", "string-hash": "^1.1.3", "tiptap": "1.20.1", - "tiptap-extensions": "1.20.2", + "tiptap-extensions": "1.21.0", "v-tooltip": "~2.0.2", "vue-count-to": "~1.0.13", "vue-izitoast": "1.1.2", "vue-sweetalert-icons": "~3.2.0", - "vue2-dropzone": "^3.5.9", "vuex-i18n": "~1.11.0", "zxcvbn": "^4.4.2" }, @@ -107,7 +106,7 @@ "nodemon": "~1.19.1", "prettier": "~1.17.1", "sass-loader": "~7.1.0", - "tippy.js": "^4.3.2", + "tippy.js": "^4.3.3", "vue-jest": "~3.0.4", "vue-svg-loader": "~0.12.0" } diff --git a/webapp/pages/index.vue b/webapp/pages/index.vue index a45a99656..d8becf206 100644 --- a/webapp/pages/index.vue +++ b/webapp/pages/index.vue @@ -1,6 +1,9 @@