diff --git a/backend/.env.template b/backend/.env.template index 52acdcc31..5aa44036b 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -47,3 +47,4 @@ AWS_REGION= AWS_BUCKET= CATEGORIES_ACTIVE=false +MAX_PINNED_POSTS=1 diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 1aa7991eb..82f0f674e 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -123,6 +123,9 @@ const options = { INVITE_CODES_GROUP_PER_USER: (env.INVITE_CODES_GROUP_PER_USER && parseInt(env.INVITE_CODES_GROUP_PER_USER)) || 7, CATEGORIES_ACTIVE: process.env.CATEGORIES_ACTIVE === 'true' || false, + MAX_PINNED_POSTS: Number.isNaN(Number(process.env.MAX_PINNED_POSTS)) + ? 1 + : Number(process.env.MAX_PINNED_POSTS), } const language = { diff --git a/backend/src/graphql/resolvers/posts.spec.ts b/backend/src/graphql/resolvers/posts.spec.ts index 8d9bf355b..fc3948d98 100644 --- a/backend/src/graphql/resolvers/posts.spec.ts +++ b/backend/src/graphql/resolvers/posts.spec.ts @@ -1096,8 +1096,20 @@ describe('pin posts', () => { authenticatedUser = await admin.toJson() }) - describe('are allowed to pin posts', () => { + const postOrderingQuery = gql` + query ($orderBy: [_PostOrdering]) { + Post(orderBy: $orderBy) { + id + pinned + createdAt + pinnedAt + } + } + ` + + describe('MAX_PINNED_POSTS is 0', () => { beforeEach(async () => { + CONFIG.MAX_PINNED_POSTS = 0 await Factory.build( 'post', { @@ -1110,217 +1122,458 @@ describe('pin posts', () => { variables = { ...variables, id: 'created-and-pinned-by-same-admin' } }) - it('responds with the updated Post', async () => { - const expected = { - data: { - pinPost: { - id: 'created-and-pinned-by-same-admin', - author: { - name: 'Admin', - }, - pinnedBy: { - id: 'current-user', - name: 'Admin', - role: 'admin', - }, - }, - }, - errors: undefined, - } - - await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( - expected, - ) - }) - - it('sets createdAt date for PINNED', async () => { - const expected = { - data: { - pinPost: { - id: 'created-and-pinned-by-same-admin', - pinnedAt: expect.any(String), - }, - }, - errors: undefined, - } - await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( - expected, - ) - }) - - it('sets redundant `pinned` property for performant ordering', async () => { - variables = { ...variables, id: 'created-and-pinned-by-same-admin' } - const expected = { - data: { pinPost: { pinned: true } }, - errors: undefined, - } - await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( - expected, - ) - }) - }) - - describe('post created by another admin', () => { - let otherAdmin - beforeEach(async () => { - otherAdmin = await Factory.build('user', { - role: 'admin', - name: 'otherAdmin', + it('throws with error that pinning posts is not allowed', async () => { + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({ + data: { pinPost: null }, + errors: [{ message: 'Pinned posts are not allowed!' }], }) - authenticatedUser = await otherAdmin.toJson() - await Factory.build( - 'post', - { - id: 'created-by-one-admin-pinned-by-different-one', - }, - { - author: otherAdmin, - }, - ) + }) + }) + + describe('MAX_PINNED_POSTS is 1', () => { + beforeEach(() => { + CONFIG.MAX_PINNED_POSTS = 1 }) - it('responds with the updated Post', async () => { - authenticatedUser = await admin.toJson() - variables = { ...variables, id: 'created-by-one-admin-pinned-by-different-one' } - const expected = { - data: { - pinPost: { + describe('are allowed to pin posts', () => { + beforeEach(async () => { + await Factory.build( + 'post', + { + id: 'created-and-pinned-by-same-admin', + }, + { + author: admin, + }, + ) + variables = { ...variables, id: 'created-and-pinned-by-same-admin' } + }) + + it('responds with the updated Post', async () => { + const expected = { + data: { + pinPost: { + id: 'created-and-pinned-by-same-admin', + author: { + name: 'Admin', + }, + pinnedBy: { + id: 'current-user', + name: 'Admin', + role: 'admin', + }, + }, + }, + errors: undefined, + } + + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + + it('sets createdAt date for PINNED', async () => { + const expected = { + data: { + pinPost: { + id: 'created-and-pinned-by-same-admin', + pinnedAt: expect.any(String), + }, + }, + errors: undefined, + } + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + + it('sets redundant `pinned` property for performant ordering', async () => { + variables = { ...variables, id: 'created-and-pinned-by-same-admin' } + const expected = { + data: { pinPost: { pinned: true } }, + errors: undefined, + } + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + }) + + describe('post created by another admin', () => { + let otherAdmin + beforeEach(async () => { + otherAdmin = await Factory.build('user', { + role: 'admin', + name: 'otherAdmin', + }) + authenticatedUser = await otherAdmin.toJson() + await Factory.build( + 'post', + { id: 'created-by-one-admin-pinned-by-different-one', - author: { - name: 'otherAdmin', - }, - pinnedBy: { - id: 'current-user', - name: 'Admin', - role: 'admin', + }, + { + author: otherAdmin, + }, + ) + }) + + it('responds with the updated Post', async () => { + authenticatedUser = await admin.toJson() + variables = { ...variables, id: 'created-by-one-admin-pinned-by-different-one' } + const expected = { + data: { + pinPost: { + id: 'created-by-one-admin-pinned-by-different-one', + author: { + name: 'otherAdmin', + }, + pinnedBy: { + id: 'current-user', + name: 'Admin', + role: 'admin', + }, }, }, - }, - errors: undefined, - } + errors: undefined, + } - await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( - expected, - ) + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + }) + + describe('post created by another user', () => { + it('responds with the updated Post', async () => { + const expected = { + data: { + pinPost: { + id: 'p9876', + author: { + slug: 'the-author', + }, + pinnedBy: { + id: 'current-user', + name: 'Admin', + role: 'admin', + }, + }, + }, + errors: undefined, + } + + await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( + expected, + ) + }) + }) + + describe('pinned post already exists', () => { + let pinnedPost + beforeEach(async () => { + await Factory.build( + 'post', + { + id: 'only-pinned-post', + }, + { + author: admin, + }, + ) + await mutate({ mutation: pinPostMutation, variables }) + }) + + it('removes previous `pinned` attribute', async () => { + const cypher = 'MATCH (post:Post) WHERE post.pinned IS NOT NULL RETURN post' + pinnedPost = await database.neode.cypher(cypher, {}) + expect(pinnedPost.records).toHaveLength(1) + variables = { ...variables, id: 'only-pinned-post' } + await mutate({ mutation: pinPostMutation, variables }) + pinnedPost = await database.neode.cypher(cypher, {}) + expect(pinnedPost.records).toHaveLength(1) + }) + + it('removes previous PINNED relationship', async () => { + variables = { ...variables, id: 'only-pinned-post' } + await mutate({ mutation: pinPostMutation, variables }) + pinnedPost = await database.neode.cypher( + `MATCH (:User)-[pinned:PINNED]->(post:Post) RETURN post, pinned`, + {}, + ) + expect(pinnedPost.records).toHaveLength(1) + }) + }) + + describe('PostOrdering', () => { + beforeEach(async () => { + await Factory.build('post', { + id: 'im-a-pinned-post', + createdAt: '2019-11-22T17:26:29.070Z', + pinned: true, + }) + await Factory.build('post', { + id: 'i-was-created-before-pinned-post', + // fairly old, so this should be 3rd + createdAt: '2019-10-22T17:26:29.070Z', + }) + }) + + describe('order by `pinned_asc` and `createdAt_desc`', () => { + beforeEach(() => { + // this is the ordering in the frontend + variables = { orderBy: ['pinned_asc', 'createdAt_desc'] } + }) + + it('pinned post appear first even when created before other posts', async () => { + await expect(query({ query: postOrderingQuery, variables })).resolves.toMatchObject({ + data: { + Post: [ + { + id: 'im-a-pinned-post', + pinned: true, + createdAt: '2019-11-22T17:26:29.070Z', + pinnedAt: expect.any(String), + }, + { + id: 'p9876', + pinned: null, + createdAt: expect.any(String), + pinnedAt: null, + }, + { + id: 'i-was-created-before-pinned-post', + pinned: null, + createdAt: '2019-10-22T17:26:29.070Z', + pinnedAt: null, + }, + ], + }, + errors: undefined, + }) + }) + }) }) }) - describe('post created by another user', () => { - it('responds with the updated Post', async () => { - const expected = { - data: { - pinPost: { - id: 'p9876', - author: { - slug: 'the-author', - }, - pinnedBy: { - id: 'current-user', - name: 'Admin', - role: 'admin', - }, - }, - }, - errors: undefined, - } + describe('MAX_PINNED_POSTS = 3', () => { + const postsPinnedCountsQuery = `query { PostsPinnedCounts { maxPinnedPosts, currentlyPinnedPosts } }` - await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject( - expected, - ) - }) - }) - - describe('pinned post already exists', () => { - let pinnedPost beforeEach(async () => { + CONFIG.MAX_PINNED_POSTS = 3 await Factory.build( 'post', { - id: 'only-pinned-post', + id: 'first-post', + createdAt: '2019-10-22T17:26:29.070Z', }, { author: admin, }, ) - await mutate({ mutation: pinPostMutation, variables }) - }) - - it('removes previous `pinned` attribute', async () => { - const cypher = 'MATCH (post:Post) WHERE post.pinned IS NOT NULL RETURN post' - pinnedPost = await database.neode.cypher(cypher, {}) - expect(pinnedPost.records).toHaveLength(1) - variables = { ...variables, id: 'only-pinned-post' } - await mutate({ mutation: pinPostMutation, variables }) - pinnedPost = await database.neode.cypher(cypher, {}) - expect(pinnedPost.records).toHaveLength(1) - }) - - it('removes previous PINNED relationship', async () => { - variables = { ...variables, id: 'only-pinned-post' } - await mutate({ mutation: pinPostMutation, variables }) - pinnedPost = await database.neode.cypher( - `MATCH (:User)-[pinned:PINNED]->(post:Post) RETURN post, pinned`, - {}, + await Factory.build( + 'post', + { + id: 'second-post', + createdAt: '2018-10-22T17:26:29.070Z', + }, + { + author: admin, + }, + ) + await Factory.build( + 'post', + { + id: 'third-post', + createdAt: '2017-10-22T17:26:29.070Z', + }, + { + author: admin, + }, + ) + await Factory.build( + 'post', + { + id: 'another-post', + }, + { + author: admin, + }, ) - expect(pinnedPost.records).toHaveLength(1) - }) - }) - - describe('PostOrdering', () => { - beforeEach(async () => { - await Factory.build('post', { - id: 'im-a-pinned-post', - createdAt: '2019-11-22T17:26:29.070Z', - pinned: true, - }) - await Factory.build('post', { - id: 'i-was-created-before-pinned-post', - // fairly old, so this should be 3rd - createdAt: '2019-10-22T17:26:29.070Z', - }) }) - describe('order by `pinned_asc` and `createdAt_desc`', () => { - beforeEach(() => { - // this is the ordering in the frontend - variables = { orderBy: ['pinned_asc', 'createdAt_desc'] } + describe('first post', () => { + let result + + beforeEach(async () => { + variables = { ...variables, id: 'first-post' } + result = await mutate({ mutation: pinPostMutation, variables }) }) - it('pinned post appear first even when created before other posts', async () => { - const postOrderingQuery = gql` - query ($orderBy: [_PostOrdering]) { - Post(orderBy: $orderBy) { - id - pinned - createdAt - pinnedAt - } - } - ` - await expect(query({ query: postOrderingQuery, variables })).resolves.toMatchObject({ + it('pins the first post', () => { + expect(result).toMatchObject({ data: { - Post: [ - { - id: 'im-a-pinned-post', - pinned: true, - createdAt: '2019-11-22T17:26:29.070Z', - pinnedAt: expect.any(String), + pinPost: { + id: 'first-post', + pinned: true, + pinnedAt: expect.any(String), + pinnedBy: { + id: 'current-user', }, - { - id: 'p9876', - pinned: null, - createdAt: expect.any(String), - pinnedAt: null, - }, - { - id: 'i-was-created-before-pinned-post', - pinned: null, - createdAt: '2019-10-22T17:26:29.070Z', - pinnedAt: null, - }, - ], + }, }, - errors: undefined, + }) + }) + + it('returns the correct counts', async () => { + await expect( + query({ + query: postsPinnedCountsQuery, + }), + ).resolves.toMatchObject({ + data: { + PostsPinnedCounts: { + maxPinnedPosts: 3, + currentlyPinnedPosts: 1, + }, + }, + }) + }) + + describe('second post', () => { + beforeEach(async () => { + variables = { ...variables, id: 'second-post' } + result = await mutate({ mutation: pinPostMutation, variables }) + }) + + it('pins the second post', () => { + expect(result).toMatchObject({ + data: { + pinPost: { + id: 'second-post', + pinned: true, + pinnedAt: expect.any(String), + pinnedBy: { + id: 'current-user', + }, + }, + }, + }) + }) + + it('returns the correct counts', async () => { + await expect( + query({ + query: postsPinnedCountsQuery, + }), + ).resolves.toMatchObject({ + data: { + PostsPinnedCounts: { + maxPinnedPosts: 3, + currentlyPinnedPosts: 2, + }, + }, + }) + }) + + describe('third post', () => { + beforeEach(async () => { + variables = { ...variables, id: 'third-post' } + result = await mutate({ mutation: pinPostMutation, variables }) + }) + + it('pins the second post', () => { + expect(result).toMatchObject({ + data: { + pinPost: { + id: 'third-post', + pinned: true, + pinnedAt: expect.any(String), + pinnedBy: { + id: 'current-user', + }, + }, + }, + }) + }) + + it('returns the correct counts', async () => { + await expect( + query({ + query: postsPinnedCountsQuery, + }), + ).resolves.toMatchObject({ + data: { + PostsPinnedCounts: { + maxPinnedPosts: 3, + currentlyPinnedPosts: 3, + }, + }, + }) + }) + + describe('another post', () => { + beforeEach(async () => { + variables = { ...variables, id: 'another-post' } + result = await mutate({ mutation: pinPostMutation, variables }) + }) + + it('throws with max pinned posts is reached', () => { + expect(result).toMatchObject({ + data: { pinPost: null }, + errors: [{ message: 'Max number of pinned posts is reached!' }], + }) + }) + }) + + describe('post ordering', () => { + beforeEach(() => { + // this is the ordering in the frontend + variables = { orderBy: ['pinned_asc', 'createdAt_desc'] } + }) + + it('places the pinned posts first, though they are much older', async () => { + await expect(query({ query: postOrderingQuery, variables })).resolves.toMatchObject( + { + data: { + Post: [ + { + id: 'first-post', + pinned: true, + pinnedAt: expect.any(String), + createdAt: '2019-10-22T17:26:29.070Z', + }, + { + id: 'second-post', + pinned: true, + pinnedAt: expect.any(String), + createdAt: '2018-10-22T17:26:29.070Z', + }, + { + id: 'third-post', + pinned: true, + pinnedAt: expect.any(String), + createdAt: '2017-10-22T17:26:29.070Z', + }, + { + id: 'another-post', + pinned: null, + pinnedAt: null, + createdAt: expect.any(String), + }, + { + id: 'p9876', + pinned: null, + pinnedAt: null, + createdAt: expect.any(String), + }, + ], + }, + errors: undefined, + }, + ) + }) + }) }) }) }) diff --git a/backend/src/graphql/resolvers/posts.ts b/backend/src/graphql/resolvers/posts.ts index f981662ba..1d23770a3 100644 --- a/backend/src/graphql/resolvers/posts.ts +++ b/backend/src/graphql/resolvers/posts.ts @@ -10,6 +10,7 @@ import { neo4jgraphql } from 'neo4j-graphql-js' import { v4 as uuid } from 'uuid' import CONFIG from '@config/index' +import { Context } from '@src/server' import { validateEventParams } from './helpers/events' import { filterForMutedUsers } from './helpers/filterForMutedUsers' @@ -96,6 +97,17 @@ export default { session.close() } }, + PostsPinnedCounts: async (_object, params, context: Context, _resolveInfo) => { + const [postsPinnedCount] = ( + await context.database.query({ + query: 'MATCH (p:Post { pinned: true }) RETURN COUNT (p) AS count', + }) + ).records.map((r) => Number(r.get('count').toString())) + return { + maxPinnedPosts: CONFIG.MAX_PINNED_POSTS, + currentlyPinnedPosts: postsPinnedCount, + } + }, }, Mutation: { CreatePost: async (_parent, params, context, _resolveInfo) => { @@ -330,56 +342,79 @@ export default { session.close() } }, - pinPost: async (_parent, params, context, _resolveInfo) => { + pinPost: async (_parent, params, context: Context, _resolveInfo) => { + if (CONFIG.MAX_PINNED_POSTS === 0) throw new Error('Pinned posts are not allowed!') let pinnedPostWithNestedAttributes const { driver, user } = context const session = driver.session() const { id: userId } = user - let writeTxResultPromise = session.writeTransaction(async (transaction) => { - const deletePreviousRelationsResponse = await transaction.run( - ` + const pinPostCypher = ` + MATCH (user:User {id: $userId}) WHERE user.role = 'admin' + MATCH (post:Post {id: $params.id}) + WHERE NOT((post)-[:IN]->(:Group)) + MERGE (user)-[pinned:PINNED {createdAt: toString(datetime())}]->(post) + SET post.pinned = true + RETURN post, pinned.createdAt as pinnedAt` + + if (CONFIG.MAX_PINNED_POSTS === 1) { + let writeTxResultPromise = session.writeTransaction(async (transaction) => { + const deletePreviousRelationsResponse = await transaction.run( + ` MATCH (:User)-[previousRelations:PINNED]->(post:Post) REMOVE post.pinned DELETE previousRelations RETURN post `, - ) - return deletePreviousRelationsResponse.records.map( - (record) => record.get('post').properties, - ) - }) - try { - await writeTxResultPromise - - writeTxResultPromise = session.writeTransaction(async (transaction) => { - const pinPostTransactionResponse = await transaction.run( - ` - MATCH (user:User {id: $userId}) WHERE user.role = 'admin' - MATCH (post:Post {id: $params.id}) - WHERE NOT((post)-[:IN]->(:Group)) - MERGE (user)-[pinned:PINNED {createdAt: toString(datetime())}]->(post) - SET post.pinned = true - RETURN post, pinned.createdAt as pinnedAt - `, - { userId, params }, ) - return pinPostTransactionResponse.records.map((record) => ({ - pinnedPost: record.get('post').properties, - pinnedAt: record.get('pinnedAt'), - })) + return deletePreviousRelationsResponse.records.map( + (record) => record.get('post').properties, + ) }) - const [transactionResult] = await writeTxResultPromise - if (transactionResult) { - const { pinnedPost, pinnedAt } = transactionResult - pinnedPostWithNestedAttributes = { - ...pinnedPost, - pinnedAt, + try { + await writeTxResultPromise + + writeTxResultPromise = session.writeTransaction(async (transaction) => { + const pinPostTransactionResponse = await transaction.run(pinPostCypher, { + userId, + params, + }) + return pinPostTransactionResponse.records.map((record) => ({ + pinnedPost: record.get('post').properties, + pinnedAt: record.get('pinnedAt'), + })) + }) + const [transactionResult] = await writeTxResultPromise + if (transactionResult) { + const { pinnedPost, pinnedAt } = transactionResult + pinnedPostWithNestedAttributes = { + ...pinnedPost, + pinnedAt, + } } + } finally { + await session.close() } - } finally { - session.close() + return pinnedPostWithNestedAttributes + } else { + const [currentPinnedPostCount] = ( + await context.database.query({ + query: `MATCH (:User)-[:PINNED]->(post:Post { pinned: true }) RETURN COUNT(post) AS count`, + }) + ).records.map((r) => Number(r.get('count').toString())) + if (currentPinnedPostCount >= CONFIG.MAX_PINNED_POSTS) { + throw new Error('Max number of pinned posts is reached!') + } + const [pinPostResult] = ( + await context.database.write({ + query: pinPostCypher, + variables: { userId, params }, + }) + ).records.map((r) => ({ + ...r.get('post').properties, + pinnedAt: r.get('pinnedAt'), + })) + return pinPostResult } - return pinnedPostWithNestedAttributes }, unpinPost: async (_parent, params, context, _resolveInfo) => { let unpinnedPost diff --git a/backend/src/graphql/types/type/Post.gql b/backend/src/graphql/types/type/Post.gql index fcaa5945a..784a5f5bc 100644 --- a/backend/src/graphql/types/type/Post.gql +++ b/backend/src/graphql/types/type/Post.gql @@ -250,6 +250,11 @@ type Mutation { toggleObservePost(id: ID!, value: Boolean!): Post! } +type PinnedPostCounts { + maxPinnedPosts: Int! + currentlyPinnedPosts: Int! +} + type Query { Post( id: ID @@ -271,4 +276,5 @@ type Query { PostsEmotionsCountByEmotion(postId: ID!, data: _EMOTEDInput!): Int! PostsEmotionsByCurrentUser(postId: ID!): [String] profilePagePosts(filter: _PostFilter, first: Int, offset: Int, orderBy: [_PostOrdering]): [Post] + PostsPinnedCounts: PinnedPostCounts! } diff --git a/backend/src/middleware/permissionsMiddleware.ts b/backend/src/middleware/permissionsMiddleware.ts index a775e2fe3..4421a909e 100644 --- a/backend/src/middleware/permissionsMiddleware.ts +++ b/backend/src/middleware/permissionsMiddleware.ts @@ -433,6 +433,7 @@ export default shield( Room: isAuthenticated, Message: isAuthenticated, UnreadRooms: isAuthenticated, + PostsPinnedCounts: isAdmin, // Invite Code validateInviteCode: allow, diff --git a/webapp/components/ContentMenu/ContentMenu.spec.js b/webapp/components/ContentMenu/ContentMenu.spec.js index ce7a45a42..2bce1496f 100644 --- a/webapp/components/ContentMenu/ContentMenu.spec.js +++ b/webapp/components/ContentMenu/ContentMenu.spec.js @@ -16,7 +16,10 @@ const stubs = { }, } -let getters, mutations, mocks, menuToggle, openModalSpy +let getters, mutations, actions, mocks, menuToggle, openModalSpy + +const maxPinnedPostsMock = jest.fn() +const currentlyPinnedPostsMock = jest.fn() describe('ContentMenu.vue', () => { beforeEach(() => { @@ -38,10 +41,15 @@ describe('ContentMenu.vue', () => { getters = { 'auth/isModerator': () => false, 'auth/isAdmin': () => false, + 'pinnedPosts/maxPinnedPosts': maxPinnedPostsMock, + 'pinnedPosts/currentlyPinnedPosts': currentlyPinnedPostsMock, + } + actions = { + 'pinnedPosts/fetch': jest.fn(), } const openContentMenu = async (values = {}) => { - const store = new Vuex.Store({ mutations, getters }) + const store = new Vuex.Store({ mutations, getters, actions }) const wrapper = mount(ContentMenu, { propsData: { ...values, @@ -91,53 +99,208 @@ describe('ContentMenu.vue', () => { }) describe('admin can', () => { - it('pin unpinned post', async () => { - getters['auth/isAdmin'] = () => true - const wrapper = await openContentMenu({ - isOwner: false, - resourceType: 'contribution', - resource: { - id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', - pinnedBy: null, - }, + describe('when maxPinnedPosts = 0', () => { + beforeEach(() => { + maxPinnedPostsMock.mockReturnValue(0) }) - wrapper - .findAll('.ds-menu-item') - .filter((item) => item.text() === 'post.menu.pin') - .at(0) - .trigger('click') - expect(wrapper.emitted('pinPost')).toEqual([ - [ - { + + it('not pin unpinned post', async () => { + getters['auth/isAdmin'] = () => true + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', pinnedBy: null, }, - ], - ]) - }) - - it('unpin pinned post', async () => { - const wrapper = await openContentMenu({ - isOwner: false, - resourceType: 'contribution', - resource: { - id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', - pinnedBy: 'someone', - }, + }) + expect( + wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.pin'), + ).toHaveLength(0) }) - wrapper - .findAll('.ds-menu-item') - .filter((item) => item.text() === 'post.menu.unpin') - .at(0) - .trigger('click') - expect(wrapper.emitted('unpinPost')).toEqual([ - [ - { + + it('unpin pinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', pinnedBy: 'someone', }, - ], - ]) + }) + wrapper + .findAll('.ds-menu-item') + .filter((item) => item.text() === 'post.menu.unpin') + .at(0) + .trigger('click') + expect(wrapper.emitted('unpinPost')).toEqual([ + [ + { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + pinnedBy: 'someone', + }, + ], + ]) + }) + }) + + describe('when maxPinnedPosts = 1', () => { + beforeEach(() => { + maxPinnedPostsMock.mockReturnValue(1) + }) + + it('pin unpinned post', async () => { + getters['auth/isAdmin'] = () => true + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + pinnedBy: null, + }, + }) + wrapper + .findAll('.ds-menu-item') + .filter((item) => item.text() === 'post.menu.pin') + .at(0) + .trigger('click') + expect(wrapper.emitted('pinPost')).toEqual([ + [ + { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + pinnedBy: null, + }, + ], + ]) + }) + + it('unpin pinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + pinnedBy: 'someone', + }, + }) + wrapper + .findAll('.ds-menu-item') + .filter((item) => item.text() === 'post.menu.unpin') + .at(0) + .trigger('click') + expect(wrapper.emitted('unpinPost')).toEqual([ + [ + { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + pinnedBy: 'someone', + }, + ], + ]) + }) + }) + + describe('when maxPinnedPosts = 3', () => { + describe('and max is not reached', () => { + beforeEach(() => { + maxPinnedPostsMock.mockReturnValue(3) + currentlyPinnedPostsMock.mockReturnValue(2) + }) + + it('pin unpinned post', async () => { + getters['auth/isAdmin'] = () => true + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + pinnedBy: null, + }, + }) + wrapper + .findAll('.ds-menu-item') + .filter((item) => item.text() === 'post.menu.pin') + .at(0) + .trigger('click') + expect(wrapper.emitted('pinPost')).toEqual([ + [ + { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + pinnedBy: null, + }, + ], + ]) + }) + + it('unpin pinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + pinnedBy: 'someone', + }, + }) + wrapper + .findAll('.ds-menu-item') + .filter((item) => item.text() === 'post.menu.unpin') + .at(0) + .trigger('click') + expect(wrapper.emitted('unpinPost')).toEqual([ + [ + { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + pinnedBy: 'someone', + }, + ], + ]) + }) + }) + + describe('and max is reached', () => { + beforeEach(() => { + maxPinnedPostsMock.mockReturnValue(3) + currentlyPinnedPostsMock.mockReturnValue(3) + }) + + it('not pin unpinned post', async () => { + getters['auth/isAdmin'] = () => true + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + pinnedBy: null, + }, + }) + expect( + wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.pin'), + ).toHaveLength(0) + }) + + it('unpin pinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + pinnedBy: 'someone', + }, + }) + wrapper + .findAll('.ds-menu-item') + .filter((item) => item.text() === 'post.menu.unpin') + .at(0) + .trigger('click') + expect(wrapper.emitted('unpinPost')).toEqual([ + [ + { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + pinnedBy: 'someone', + }, + ], + ]) + }) + }) }) it('can delete another user', async () => { diff --git a/webapp/components/ContentMenu/ContentMenu.vue b/webapp/components/ContentMenu/ContentMenu.vue index 627e5d982..3f6f742a1 100644 --- a/webapp/components/ContentMenu/ContentMenu.vue +++ b/webapp/components/ContentMenu/ContentMenu.vue @@ -32,12 +32,14 @@