From 6fc3c038607422410ef74f9ddc6fd242996cc130 Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Wed, 28 Jan 2026 16:53:29 +0100 Subject: [PATCH] feat(backend): group pins (#9034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Wolfgang Huß --- backend/.env.template | 1 + backend/.env.test_e2e | 1 + backend/jest.config.cjs | 2 +- backend/src/config/index.ts | 3 + backend/src/db/models/Post.ts | 1 + backend/src/graphql/queries/pinGroupPost.ts | 25 + .../src/graphql/queries/profilePagePosts.ts | 1 + backend/src/graphql/queries/unpinGroupPost.ts | 25 + backend/src/graphql/resolvers/groups.ts | 12 + .../graphql/resolvers/posts.group.pin.spec.ts | 368 ++++++++ backend/src/graphql/resolvers/posts.ts | 93 ++ backend/src/graphql/types/type/Group.gql | 2 + backend/src/graphql/types/type/Post.gql | 8 + .../src/middleware/permissionsMiddleware.ts | 22 + backend/test/helpers.ts | 1 + webapp/.env.template | 3 +- .../ContentMenu/ContentMenu.Group.spec.js | 823 ++++++++++++++++++ webapp/components/ContentMenu/ContentMenu.vue | 26 + .../components/PostTeaser/PostTeaser.spec.js | 4 + webapp/components/PostTeaser/PostTeaser.vue | 17 +- .../SearchResults/SearchResults.spec.js | 11 +- .../features/SearchResults/SearchResults.vue | 3 + webapp/config/index.js | 3 + webapp/graphql/PostMutations.js | 34 + webapp/graphql/PostQuery.js | 18 - webapp/graphql/fragments/post.js | 9 + webapp/locales/de.json | 4 + webapp/locales/en.json | 4 + webapp/locales/es.json | 4 + webapp/locales/fr.json | 4 + webapp/locales/it.json | 4 + webapp/locales/nl.json | 4 + webapp/locales/pl.json | 4 + webapp/locales/pt.json | 4 + webapp/locales/ru.json | 4 + webapp/mixins/postListActions.js | 30 + webapp/pages/groups/_id/_slug.vue | 9 +- webapp/pages/index.vue | 2 + webapp/pages/post/_id/_slug/index.vue | 2 + webapp/pages/profile/_id/_slug.spec.js | 4 + webapp/pages/profile/_id/_slug.vue | 2 + 41 files changed, 1571 insertions(+), 30 deletions(-) create mode 100644 backend/src/graphql/queries/pinGroupPost.ts create mode 100644 backend/src/graphql/queries/unpinGroupPost.ts create mode 100644 backend/src/graphql/resolvers/posts.group.pin.spec.ts create mode 100644 webapp/components/ContentMenu/ContentMenu.Group.spec.js diff --git a/backend/.env.template b/backend/.env.template index f8187b0ca..b9ec83f94 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -48,3 +48,4 @@ IMAGOR_SECRET=mysecret CATEGORIES_ACTIVE=false MAX_PINNED_POSTS=1 +MAX_GROUP_PINNED_POSTS=1 diff --git a/backend/.env.test_e2e b/backend/.env.test_e2e index feadc874a..62bf7bab7 100644 --- a/backend/.env.test_e2e +++ b/backend/.env.test_e2e @@ -40,3 +40,4 @@ IMAGOR_SECRET=mysecret CATEGORIES_ACTIVE=false MAX_PINNED_POSTS=1 +MAX_GROUP_PINNED_POSTS=1 diff --git a/backend/jest.config.cjs b/backend/jest.config.cjs index 285654e3a..c78de9155 100644 --- a/backend/jest.config.cjs +++ b/backend/jest.config.cjs @@ -18,7 +18,7 @@ module.exports = { ], coverageThreshold: { global: { - lines: 93, + lines: 92, }, }, testMatch: ['**/src/**/?(*.)+(spec|test).ts?(x)'], diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index dedff2d94..f424ce754 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -138,6 +138,9 @@ const options = { MAX_PINNED_POSTS: Number.isNaN(Number(process.env.MAX_PINNED_POSTS)) ? 1 : Number(process.env.MAX_PINNED_POSTS), + MAX_GROUP_PINNED_POSTS: Number.isNaN(Number(process.env.MAX_GROUP_PINNED_POSTS)) + ? 1 + : Number(process.env.MAX_GROUP_PINNED_POSTS), } const language = { diff --git a/backend/src/db/models/Post.ts b/backend/src/db/models/Post.ts index 5a42a4182..f3a6f7155 100644 --- a/backend/src/db/models/Post.ts +++ b/backend/src/db/models/Post.ts @@ -58,6 +58,7 @@ export default { }, }, pinned: { type: 'boolean', default: null, valid: [null, true] }, + groupPinned: { type: 'boolean', default: null, valid: [null, true] }, postType: { type: 'string', default: 'Article', valid: ['Article', 'Event'] }, observes: { type: 'relationship', diff --git a/backend/src/graphql/queries/pinGroupPost.ts b/backend/src/graphql/queries/pinGroupPost.ts new file mode 100644 index 000000000..e22b42995 --- /dev/null +++ b/backend/src/graphql/queries/pinGroupPost.ts @@ -0,0 +1,25 @@ +import gql from 'graphql-tag' + +export const pinGroupPost = gql` + mutation ($id: ID!) { + pinGroupPost(id: $id) { + id + title + content + author { + name + slug + } + pinnedBy { + id + name + role + } + createdAt + updatedAt + pinnedAt + pinned + groupPinned + } + } +` diff --git a/backend/src/graphql/queries/profilePagePosts.ts b/backend/src/graphql/queries/profilePagePosts.ts index 07ce18f40..f54188d17 100644 --- a/backend/src/graphql/queries/profilePagePosts.ts +++ b/backend/src/graphql/queries/profilePagePosts.ts @@ -11,6 +11,7 @@ export const profilePagePosts = gql` id title content + groupPinned } } ` diff --git a/backend/src/graphql/queries/unpinGroupPost.ts b/backend/src/graphql/queries/unpinGroupPost.ts new file mode 100644 index 000000000..79f658d95 --- /dev/null +++ b/backend/src/graphql/queries/unpinGroupPost.ts @@ -0,0 +1,25 @@ +import gql from 'graphql-tag' + +export const unpinGroupPost = gql` + mutation ($id: ID!) { + unpinGroupPost(id: $id) { + id + title + content + author { + name + slug + } + pinnedBy { + id + name + role + } + createdAt + updatedAt + pinned + pinnedAt + groupPinned + } + } +` diff --git a/backend/src/graphql/resolvers/groups.ts b/backend/src/graphql/resolvers/groups.ts index 9ccf342d3..be493062a 100644 --- a/backend/src/graphql/resolvers/groups.ts +++ b/backend/src/graphql/resolvers/groups.ts @@ -471,6 +471,18 @@ export default { }) ).records.map((r) => r.get('inviteCodes')) }, + currentlyPinnedPostsCount: async (parent, _args, context: Context, _resolveInfo) => { + if (!parent.id) { + throw new Error('Can not identify selected Group!') + } + const result = await context.database.query({ + query: ` + MATCH (:User)-[pinned:GROUP_PINNED]->(pinnedPosts:Post)-[:IN]->(:Group {id: $group.id}) + RETURN toString(count(pinnedPosts)) as count`, + variables: { group: parent }, + }) + return result.records[0].get('count') + }, ...Resolver('Group', { undefinedToNull: ['deleted', 'disabled', 'locationName', 'about'], hasMany: { diff --git a/backend/src/graphql/resolvers/posts.group.pin.spec.ts b/backend/src/graphql/resolvers/posts.group.pin.spec.ts new file mode 100644 index 000000000..1115be332 --- /dev/null +++ b/backend/src/graphql/resolvers/posts.group.pin.spec.ts @@ -0,0 +1,368 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import Factory, { cleanDatabase } from '@db/factories' +import { ChangeGroupMemberRole } from '@graphql/queries/ChangeGroupMemberRole' +import { CreateGroup } from '@graphql/queries/CreateGroup' +import { CreatePost } from '@graphql/queries/CreatePost' +import { pinGroupPost } from '@graphql/queries/pinGroupPost' +import { profilePagePosts } from '@graphql/queries/profilePagePosts' +import { unpinGroupPost } from '@graphql/queries/unpinGroupPost' +import type { ApolloTestSetup } from '@root/test/helpers' +import { createApolloTestSetup } from '@root/test/helpers' +import type { Context } from '@src/context' + +const defaultConfig = { + CATEGORIES_ACTIVE: false, +} +let config: Partial + +let anyUser +let allGroupsUser +let publicUser +let publicAdminUser +let authenticatedUser: Context['user'] +const context = () => ({ authenticatedUser, config }) +let mutate: ApolloTestSetup['mutate'] +let query: ApolloTestSetup['query'] +let database: ApolloTestSetup['database'] +let server: ApolloTestSetup['server'] + +beforeAll(async () => { + await cleanDatabase() + const apolloSetup = createApolloTestSetup({ context }) + mutate = apolloSetup.mutate + query = apolloSetup.query + database = apolloSetup.database + server = apolloSetup.server +}) + +afterAll(() => { + void server.stop() + void database.driver.close() + database.neode.close() +}) + +beforeEach(async () => { + config = { ...defaultConfig } + authenticatedUser = null + + anyUser = await Factory.build('user', { + id: 'any-user', + name: 'Any User', + about: 'I am just an ordinary user and do not belong to any group.', + }) + + allGroupsUser = await Factory.build('user', { + id: 'all-groups-user', + name: 'All Groups User', + about: 'I am a member of all groups.', + }) + publicUser = await Factory.build('user', { + id: 'public-user', + name: 'Public User', + about: 'I am the owner of the public group.', + }) + publicAdminUser = await Factory.build('user', { + id: 'public-admin-user', + name: 'Public Admin User', + about: 'I am the admin of the public group.', + }) + + authenticatedUser = await publicUser.toJson() + await mutate({ + mutation: CreateGroup, + variables: { + id: 'public-group', + name: 'The Public Group', + about: 'The public group!', + description: 'Anyone can see the posts of this group.', + groupType: 'public', + actionRadius: 'regional', + }, + }) + await mutate({ + mutation: ChangeGroupMemberRole, + variables: { + groupId: 'public-group', + userId: 'all-groups-user', + roleInGroup: 'usual', + }, + }) + await mutate({ + mutation: ChangeGroupMemberRole, + variables: { + groupId: 'public-group', + userId: 'public-admin-user', + roleInGroup: 'admin', + }, + }) + await mutate({ + mutation: ChangeGroupMemberRole, + variables: { + groupId: 'closed-group', + userId: 'all-groups-user', + roleInGroup: 'usual', + }, + }) + authenticatedUser = await anyUser.toJson() + await mutate({ + mutation: CreatePost, + variables: { + id: 'post-without-group', + title: 'A post without a group', + content: 'I am a user who does not belong to a group yet.', + }, + }) + authenticatedUser = await publicUser.toJson() + await mutate({ + mutation: CreatePost, + variables: { + id: 'post-1-to-public-group', + title: 'Post 1 to a public group', + content: 'I am posting into a public group as a member of the group', + groupId: 'public-group', + }, + }) + await mutate({ + mutation: CreatePost, + variables: { + id: 'post-2-to-public-group', + title: 'Post 1 to a public group', + content: 'I am posting into a public group as a member of the group', + groupId: 'public-group', + }, + }) + await mutate({ + mutation: CreatePost, + variables: { + id: 'post-3-to-public-group', + title: 'Post 1 to a public group', + content: 'I am posting into a public group as a member of the group', + groupId: 'public-group', + }, + }) +}) + +afterEach(async () => { + await cleanDatabase() +}) + +describe('pin groupPosts', () => { + describe('unauthenticated', () => { + it('throws authorization error', async () => { + authenticatedUser = null + await expect( + mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } }), + ).resolves.toMatchObject({ + errors: [{ message: 'Not Authorized!' }], + data: { pinGroupPost: null }, + }) + }) + }) + + describe('ordinary users', () => { + it('throws authorization error', async () => { + authenticatedUser = await anyUser.toJson() + await expect( + mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } }), + ).resolves.toMatchObject({ + errors: [{ message: 'Not Authorized!' }], + data: { pinGroupPost: null }, + }) + }) + }) + + describe('group usual', () => { + it('throws authorization error', async () => { + authenticatedUser = await allGroupsUser.toJson() + await expect( + mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } }), + ).resolves.toMatchObject({ + errors: [{ message: 'Not Authorized!' }], + data: { pinGroupPost: null }, + }) + }) + }) + + describe('group admin', () => { + it('resolves without error', async () => { + authenticatedUser = await publicAdminUser.toJson() + await expect( + mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } }), + ).resolves.toMatchObject({ + errors: undefined, + data: { pinGroupPost: { id: 'post-1-to-public-group', groupPinned: true } }, + }) + }) + }) + + describe('group owner', () => { + it('resolves without error', async () => { + authenticatedUser = await publicUser.toJson() + await expect( + mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } }), + ).resolves.toMatchObject({ + errors: undefined, + data: { pinGroupPost: { id: 'post-1-to-public-group', groupPinned: true } }, + }) + }) + }) + + describe('MAX_GROUP_PINNED_POSTS is 1', () => { + beforeEach(async () => { + config = { ...defaultConfig, MAX_GROUP_PINNED_POSTS: 1 } + authenticatedUser = await publicUser.toJson() + }) + it('returns post-1-to-public-group as first, pinned post', async () => { + await mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } }) + await expect( + query({ + query: profilePagePosts, + variables: { + filter: { group: { id: 'public-group' } }, + orderBy: ['groupPinned_asc', 'sortDate_desc'], + }, + }), + ).resolves.toMatchObject({ + errors: undefined, + data: { + profilePagePosts: [ + expect.objectContaining({ id: 'post-1-to-public-group', groupPinned: true }), + expect.objectContaining({ id: 'post-3-to-public-group', groupPinned: null }), + expect.objectContaining({ id: 'post-2-to-public-group', groupPinned: null }), + ], + }, + }) + }) + + it('no error thrown when pinned post was pinned again', async () => { + await mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } }) + await expect( + mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } }), + ).resolves.toMatchObject({ + errors: undefined, + data: { pinGroupPost: { id: 'post-1-to-public-group', groupPinned: true } }, + }) + }) + + it('returns post-2-to-public-group as first, pinned post', async () => { + authenticatedUser = await publicUser.toJson() + await mutate({ mutation: pinGroupPost, variables: { id: 'post-2-to-public-group' } }) + await expect( + query({ + query: profilePagePosts, + variables: { + filter: { group: { id: 'public-group' } }, + orderBy: ['groupPinned_asc', 'sortDate_desc'], + }, + }), + ).resolves.toMatchObject({ + errors: undefined, + data: { + profilePagePosts: [ + expect.objectContaining({ id: 'post-2-to-public-group', groupPinned: true }), + expect.objectContaining({ id: 'post-3-to-public-group', groupPinned: null }), + expect.objectContaining({ id: 'post-1-to-public-group', groupPinned: null }), + ], + }, + }) + }) + + it('returns post-3-to-public-group as first, pinned post, when multiple are pinned', async () => { + authenticatedUser = await publicUser.toJson() + await mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } }) + await mutate({ mutation: pinGroupPost, variables: { id: 'post-2-to-public-group' } }) + await mutate({ mutation: pinGroupPost, variables: { id: 'post-3-to-public-group' } }) + await expect( + query({ + query: profilePagePosts, + variables: { + filter: { group: { id: 'public-group' } }, + orderBy: ['groupPinned_asc', 'sortDate_desc'], + }, + }), + ).resolves.toMatchObject({ + errors: undefined, + data: { + profilePagePosts: [ + expect.objectContaining({ id: 'post-3-to-public-group', groupPinned: true }), + expect.objectContaining({ id: 'post-2-to-public-group', groupPinned: null }), + expect.objectContaining({ id: 'post-1-to-public-group', groupPinned: null }), + ], + }, + }) + }) + }) + + describe('MAX_GROUP_PINNED_POSTS is 2', () => { + beforeEach(async () => { + config = { ...defaultConfig, MAX_GROUP_PINNED_POSTS: 2 } + authenticatedUser = await publicUser.toJson() + }) + it('returns post-1-to-public-group as first, post-2-to-public-group as second pinned post', async () => { + await mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } }) + await mutate({ mutation: pinGroupPost, variables: { id: 'post-2-to-public-group' } }) + await expect( + query({ + query: profilePagePosts, + variables: { + filter: { group: { id: 'public-group' } }, + orderBy: ['groupPinned_asc', 'sortDate_desc'], + }, + }), + ).resolves.toMatchObject({ + errors: undefined, + data: { + profilePagePosts: [ + expect.objectContaining({ id: 'post-2-to-public-group', groupPinned: true }), + expect.objectContaining({ id: 'post-1-to-public-group', groupPinned: true }), + expect.objectContaining({ id: 'post-3-to-public-group', groupPinned: null }), + ], + }, + }) + }) + + it('throws an error when three posts are pinned', async () => { + await mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } }) + await mutate({ mutation: pinGroupPost, variables: { id: 'post-2-to-public-group' } }) + await expect( + mutate({ mutation: pinGroupPost, variables: { id: 'post-3-to-public-group' } }), + ).resolves.toMatchObject({ + errors: [{ message: 'Reached maxed pinned posts already. Unpin a post first.' }], + data: { + pinGroupPost: null, + }, + }) + }) + + it('throws no error when first unpinned before a third post is pinned', async () => { + await mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } }) + await mutate({ mutation: pinGroupPost, variables: { id: 'post-2-to-public-group' } }) + await mutate({ mutation: unpinGroupPost, variables: { id: 'post-1-to-public-group' } }) + await expect( + mutate({ mutation: pinGroupPost, variables: { id: 'post-3-to-public-group' } }), + ).resolves.toMatchObject({ + errors: undefined, + }) + await expect( + query({ + query: profilePagePosts, + variables: { + filter: { group: { id: 'public-group' } }, + orderBy: ['groupPinned_asc', 'sortDate_desc'], + }, + }), + ).resolves.toMatchObject({ + errors: undefined, + data: { + profilePagePosts: [ + expect.objectContaining({ id: 'post-3-to-public-group', groupPinned: true }), + expect.objectContaining({ id: 'post-2-to-public-group', groupPinned: true }), + expect.objectContaining({ id: 'post-1-to-public-group', groupPinned: null }), + ], + }, + }) + }) + }) +}) diff --git a/backend/src/graphql/resolvers/posts.ts b/backend/src/graphql/resolvers/posts.ts index 7d419fcbb..b63bdee77 100644 --- a/backend/src/graphql/resolvers/posts.ts +++ b/backend/src/graphql/resolvers/posts.ts @@ -29,6 +29,20 @@ const maintainPinnedPosts = (params) => { return params } +const maintainGroupPinnedPosts = (params) => { + // only show GroupPinnedPosts when Groups is selected + if (!params.filter?.group) { + return params + } + const pinnedPostFilter = { groupPinned: true, group: params.filter.group } + if (isEmpty(params.filter)) { + params.filter = { OR: [pinnedPostFilter, {}] } + } else { + params.filter = { OR: [pinnedPostFilter, { ...params.filter }] } + } + return params +} + const filterEventDates = (params) => { if (params.filter?.eventStart_gte) { const date = params.filter.eventStart_gte @@ -52,6 +66,7 @@ export default { params = await filterPostsOfMyGroups(params, context) params = await filterInvisiblePosts(params, context) params = await filterForMutedUsers(params, context) + params = await maintainGroupPinnedPosts(params) return neo4jgraphql(object, params, context, resolveInfo) }, PostsEmotionsCountByEmotion: async (_object, params, context, _resolveInfo) => { @@ -453,6 +468,68 @@ export default { } return unpinnedPost }, + pinGroupPost: async (_parent, params, context: Context, _resolveInfo) => { + if (!context.user) { + throw new Error('Missing authenticated user.') + } + const { config } = context + + if (config.MAX_GROUP_PINNED_POSTS === 0) { + throw new Error('Pinned posts are not allowed!') + } + + // If MAX_GROUP_PINNED_POSTS === 1 -> Delete old pin + if (config.MAX_GROUP_PINNED_POSTS === 1) { + await context.database.write({ + query: ` + MATCH (post:Post {id: $params.id})-[:IN]->(group:Group) + MATCH (:User)-[pinned:GROUP_PINNED]->(oldPinnedPost:Post)-[:IN]->(:Group {id: group.id}) + REMOVE oldPinnedPost.groupPinned + DELETE pinned`, + variables: { user: context.user, params }, + }) + // If MAX_GROUP_PINNED_POSTS !== 1 -> Check if max is reached + } else { + const result = await context.database.query({ + query: ` + MATCH (post:Post {id: $params.id})-[:IN]->(group:Group) + MATCH (:User)-[pinned:GROUP_PINNED]->(pinnedPosts:Post)-[:IN]->(:Group {id: group.id}) + RETURN toString(count(pinnedPosts)) as count`, + variables: { user: context.user, params }, + }) + if (result.records[0].get('count') >= config.MAX_GROUP_PINNED_POSTS) { + throw new Error('Reached maxed pinned posts already. Unpin a post first.') + } + } + + // Set new pin + const result = await context.database.write({ + query: ` + MATCH (user:User {id: $user.id}) + MATCH (post:Post {id: $params.id})-[:IN]->(group:Group) + MERGE (user)-[pinned:GROUP_PINNED {createdAt: toString(datetime())}]->(post) + SET post.groupPinned = true + RETURN post {.*, pinnedAt: pinned.createdAt}`, + variables: { user: context.user, params }, + }) + + // Return post + return result.records[0].get('post') + }, + unpinGroupPost: async (_parent, params, context, _resolveInfo) => { + const result = await context.database.write({ + query: ` + MATCH (post:Post {id: $postId}) + OPTIONAL MATCH (:User)-[pinned:GROUP_PINNED]->(post) + DELETE pinned + REMOVE post.groupPinned + RETURN post {.*}`, + variables: { postId: params.id }, + }) + + // Return post + return result.records[0].get('post') + }, markTeaserAsViewed: async (_parent, params, context, _resolveInfo) => { const session = context.driver.session() const writeTxResultPromise = session.writeTransaction(async (transaction) => { @@ -550,6 +627,7 @@ export default { 'language', 'pinnedAt', 'pinned', + 'groupPinned', 'eventVenue', 'eventLocation', 'eventLocationName', @@ -589,6 +667,21 @@ export default { 'MATCH (this)<-[obs:OBSERVES]-(related:User {id: $cypherParams.currentUserId}) WHERE obs.active = true RETURN COUNT(related) >= 1', }, }), + // As long as we rely on the filter capabilities of the neo4jgraphql library, + // we cannot filter on a relation or their properties. + // Hence we need to save the value to the group node in the database. + /* groupPinned: async (parent, _params, context, _resolveInfo) => { + return ( + ( + await context.database.query({ + query: ` + MATCH (:User)-[pinned:GROUP_PINNED]->(:Post {id: $parent.id}) + RETURN pinned`, + variables: { parent }, + }) + ).records.length === 1 + ) + }, */ relatedContributions: async (parent, _params, context, _resolveInfo) => { if (typeof parent.relatedContributions !== 'undefined') return parent.relatedContributions const { id } = parent diff --git a/backend/src/graphql/types/type/Group.gql b/backend/src/graphql/types/type/Group.gql index d93b52289..4b9ec614e 100644 --- a/backend/src/graphql/types/type/Group.gql +++ b/backend/src/graphql/types/type/Group.gql @@ -51,6 +51,8 @@ type Group { "inviteCodes to this group the current user has generated" inviteCodes: [InviteCode]! @neo4j_ignore + + currentlyPinnedPostsCount: Int! @neo4j_ignore } input _GroupFilter { diff --git a/backend/src/graphql/types/type/Post.gql b/backend/src/graphql/types/type/Post.gql index 655617d16..b30aaa938 100644 --- a/backend/src/graphql/types/type/Post.gql +++ b/backend/src/graphql/types/type/Post.gql @@ -53,6 +53,7 @@ input _PostFilter { language_in: [String!] language_not_in: [String!] pinned: Boolean # required for `maintainPinnedPost` + groupPinned: Boolean # required for `maintainGroupPinnedPost` tags: _TagFilter tags_not: _TagFilter tags_in: [_TagFilter!] @@ -115,6 +116,8 @@ enum _PostOrdering { pinned_desc eventStart_asc eventStart_desc + groupPinned_asc + groupPinned_desc } type Post { @@ -131,6 +134,7 @@ type Post { deleted: Boolean disabled: Boolean pinned: Boolean + groupPinned: Boolean createdAt: String updatedAt: String sortDate: String @@ -246,8 +250,12 @@ type Mutation { DeletePost(id: ID!): Post AddPostEmotions(to: _PostInput!, data: _EMOTEDInput!): EMOTED RemovePostEmotions(to: _PostInput!, data: _EMOTEDInput!): EMOTED + pinPost(id: ID!): Post unpinPost(id: ID!): Post + pinGroupPost(id: ID!): Post + unpinGroupPost(id: ID!): Post + markTeaserAsViewed(id: ID!): Post pushPost(id: ID!): Post! unpushPost(id: ID!): Post! diff --git a/backend/src/middleware/permissionsMiddleware.ts b/backend/src/middleware/permissionsMiddleware.ts index 0e85a43af..aea2c46f0 100644 --- a/backend/src/middleware/permissionsMiddleware.ts +++ b/backend/src/middleware/permissionsMiddleware.ts @@ -397,6 +397,26 @@ const isAllowedToGenerateGroupInviteCode = rule({ ).records[0].get('count') }) +const isAllowedToPinGroupPost = rule({ + cache: 'no_cache', +})(async (_parent, args, context: Context) => { + if (!context.user) return false + + return ( + ( + await context.database.query({ + query: ` + MATCH (post:Post{id: $args.id})-[:IN]->(group:Group) + MATCH (user:User{id: $user.id})-[membership:MEMBER_OF]->(group) + WHERE (membership.role IN ['admin', 'owner']) + RETURN toString(count(group)) as count + `, + variables: { user: context.user, args }, + }) + ).records[0].get('count') === '1' + ) +}) + // Permissions export default shield( { @@ -485,6 +505,8 @@ export default shield( VerifyEmailAddress: isAuthenticated, pinPost: isAdmin, unpinPost: isAdmin, + pinGroupPost: isAllowedToPinGroupPost, + unpinGroupPost: isAllowedToPinGroupPost, pushPost: isAdmin, unpushPost: isAdmin, UpdateDonations: isAdmin, diff --git a/backend/test/helpers.ts b/backend/test/helpers.ts index 0475c4565..d359c83ab 100644 --- a/backend/test/helpers.ts +++ b/backend/test/helpers.ts @@ -57,6 +57,7 @@ export const TEST_CONFIG = { INVITE_CODES_GROUP_PER_USER: 7, CATEGORIES_ACTIVE: false, MAX_PINNED_POSTS: 1, + MAX_GROUP_PINNED_POSTS: 1, LANGUAGE_DEFAULT: 'en', LOG_LEVEL: 'DEBUG', diff --git a/webapp/.env.template b/webapp/.env.template index 344bf8b43..1816d9df2 100644 --- a/webapp/.env.template +++ b/webapp/.env.template @@ -11,4 +11,5 @@ NETWORK_NAME="Ocelot.social" ASK_FOR_REAL_NAME=false -REQUIRE_LOCATION=false \ No newline at end of file +REQUIRE_LOCATION=false +MAX_GROUP_PINNED_POSTS=1 \ No newline at end of file diff --git a/webapp/components/ContentMenu/ContentMenu.Group.spec.js b/webapp/components/ContentMenu/ContentMenu.Group.spec.js new file mode 100644 index 000000000..6e01f6e0d --- /dev/null +++ b/webapp/components/ContentMenu/ContentMenu.Group.spec.js @@ -0,0 +1,823 @@ +import { mount, createLocalVue } from '@vue/test-utils' +import Vuex from 'vuex' +import VTooltip from 'v-tooltip' +import Styleguide from '@@/' +import ContentMenu from './ContentMenu.vue' + +const localVue = createLocalVue() + +localVue.use(Styleguide) +localVue.use(VTooltip) +localVue.use(Vuex) + +let mocks + +describe('ContentMenu.vue - Group', () => { + beforeEach(() => { + mocks = { + $t: jest.fn((str) => str), + $i18n: { + locale: () => 'en', + }, + $router: { + push: jest.fn(), + }, + $env: { + MAX_GROUP_PINNED_POSTS: 0, + }, + } + }) + + const stubs = { + 'router-link': { + template: '', + }, + } + + const mutations = { + 'modal/SET_OPEN': jest.fn(), + } + const getters = { + 'auth/isModerator': () => false, + 'auth/isAdmin': () => false, + 'pinnedPosts/maxPinnedPosts': () => 1, + 'pinnedPosts/currentlyPinnedPosts': () => 1, + } + const actions = { + 'pinnedPosts/fetch': jest.fn(), + } + + const openContentMenu = async (values = {}) => { + const store = new Vuex.Store({ mutations, getters, actions }) + const wrapper = mount(ContentMenu, { + propsData: { + ...values, + }, + mocks, + store, + localVue, + stubs, + }) + const menuToggle = wrapper.find('[data-test="content-menu-button"]') + await menuToggle.trigger('click') + return wrapper + } + + describe('as group owner', () => { + const myRole = 'owner' + + describe('when maxGroupPinnedPosts = 0', () => { + beforeEach(() => { + mocks.$env = { + MAX_GROUP_PINNED_POSTS: 0, + } + }) + + it('can not pin unpinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: false, + group: { + myRole, + }, + }, + }) + expect( + wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.groupPin'), + ).toHaveLength(0) + }) + + it('can unpin pinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: true, + group: { + myRole, + }, + }, + }) + wrapper + .findAll('.ds-menu-item') + .filter((item) => item.text() === 'post.menu.groupUnpin') + .at(0) + .trigger('click') + expect(wrapper.emitted('unpinGroupPost')).toEqual([ + [ + { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: true, + group: { + myRole, + }, + }, + ], + ]) + }) + }) + + describe('when maxPinnedPosts = 1', () => { + beforeEach(() => { + mocks.$env = { + MAX_GROUP_PINNED_POSTS: 1, + } + }) + + describe('when currentlyPinnedPostsCount = 0', () => { + const currentlyPinnedPostsCount = 0 + + it('pin unpinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: false, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + }) + wrapper + .findAll('.ds-menu-item') + .filter((item) => item.text() === 'post.menu.groupPin') + .at(0) + .trigger('click') + expect(wrapper.emitted('pinGroupPost')).toEqual([ + [ + { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: false, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + ], + ]) + }) + + it('unpin pinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: true, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + }) + wrapper + .findAll('.ds-menu-item') + .filter((item) => item.text() === 'post.menu.groupUnpin') + .at(0) + .trigger('click') + expect(wrapper.emitted('unpinGroupPost')).toEqual([ + [ + { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: true, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + ], + ]) + }) + }) + + describe('when currentlyPinnedPostsCount = 1', () => { + const currentlyPinnedPostsCount = 1 + + it('pin unpinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: false, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + }) + wrapper + .findAll('.ds-menu-item') + .filter((item) => item.text() === 'post.menu.groupPin') + .at(0) + .trigger('click') + expect(wrapper.emitted('pinGroupPost')).toEqual([ + [ + { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: false, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + ], + ]) + }) + + it('unpin pinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: true, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + }) + wrapper + .findAll('.ds-menu-item') + .filter((item) => item.text() === 'post.menu.groupUnpin') + .at(0) + .trigger('click') + expect(wrapper.emitted('unpinGroupPost')).toEqual([ + [ + { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: true, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + ], + ]) + }) + }) + }) + + describe('when maxPinnedPosts = 2', () => { + beforeEach(() => { + mocks.$env = { + MAX_GROUP_PINNED_POSTS: 2, + } + }) + + describe('when currentlyPinnedPostsCount = 1', () => { + const currentlyPinnedPostsCount = 1 + + it('pin unpinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: false, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + }) + wrapper + .findAll('.ds-menu-item') + .filter((item) => item.text() === 'post.menu.groupPin') + .at(0) + .trigger('click') + expect(wrapper.emitted('pinGroupPost')).toEqual([ + [ + { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: false, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + ], + ]) + }) + + it('unpin pinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: true, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + }) + wrapper + .findAll('.ds-menu-item') + .filter((item) => item.text() === 'post.menu.groupUnpin') + .at(0) + .trigger('click') + expect(wrapper.emitted('unpinGroupPost')).toEqual([ + [ + { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: true, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + ], + ]) + }) + }) + + describe('when currentlyPinnedPostsCount = 2', () => { + const currentlyPinnedPostsCount = 2 + + it('pin unpinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: false, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + }) + expect( + wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.groupPin') + .length, + ).toEqual(0) + }) + + it('unpin pinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: true, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + }) + wrapper + .findAll('.ds-menu-item') + .filter((item) => item.text() === 'post.menu.groupUnpin') + .at(0) + .trigger('click') + expect(wrapper.emitted('unpinGroupPost')).toEqual([ + [ + { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: true, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + ], + ]) + }) + }) + }) + }) + + describe('as group admin', () => { + const myRole = 'admin' + + describe('when maxGroupPinnedPosts = 0', () => { + beforeEach(() => { + mocks.$env = { + MAX_GROUP_PINNED_POSTS: 0, + } + }) + + it('can not pin unpinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: false, + group: { + myRole, + }, + }, + }) + expect( + wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.groupPin'), + ).toHaveLength(0) + }) + + it('can unpin pinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: true, + group: { + myRole, + }, + }, + }) + wrapper + .findAll('.ds-menu-item') + .filter((item) => item.text() === 'post.menu.groupUnpin') + .at(0) + .trigger('click') + expect(wrapper.emitted('unpinGroupPost')).toEqual([ + [ + { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: true, + group: { + myRole, + }, + }, + ], + ]) + }) + }) + + describe('when maxPinnedPosts = 1', () => { + beforeEach(() => { + mocks.$env = { + MAX_GROUP_PINNED_POSTS: 1, + } + }) + + describe('when currentlyPinnedPostsCount = 0', () => { + const currentlyPinnedPostsCount = 0 + + it('pin unpinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: false, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + }) + wrapper + .findAll('.ds-menu-item') + .filter((item) => item.text() === 'post.menu.groupPin') + .at(0) + .trigger('click') + expect(wrapper.emitted('pinGroupPost')).toEqual([ + [ + { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: false, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + ], + ]) + }) + + it('unpin pinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: true, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + }) + wrapper + .findAll('.ds-menu-item') + .filter((item) => item.text() === 'post.menu.groupUnpin') + .at(0) + .trigger('click') + expect(wrapper.emitted('unpinGroupPost')).toEqual([ + [ + { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: true, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + ], + ]) + }) + }) + + describe('when currentlyPinnedPostsCount = 1', () => { + const currentlyPinnedPostsCount = 1 + + it('pin unpinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: false, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + }) + wrapper + .findAll('.ds-menu-item') + .filter((item) => item.text() === 'post.menu.groupPin') + .at(0) + .trigger('click') + expect(wrapper.emitted('pinGroupPost')).toEqual([ + [ + { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: false, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + ], + ]) + }) + + it('unpin pinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: true, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + }) + wrapper + .findAll('.ds-menu-item') + .filter((item) => item.text() === 'post.menu.groupUnpin') + .at(0) + .trigger('click') + expect(wrapper.emitted('unpinGroupPost')).toEqual([ + [ + { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: true, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + ], + ]) + }) + }) + }) + + describe('when maxPinnedPosts = 2', () => { + beforeEach(() => { + mocks.$env = { + MAX_GROUP_PINNED_POSTS: 2, + } + }) + + describe('when currentlyPinnedPostsCount = 1', () => { + const currentlyPinnedPostsCount = 1 + + it('pin unpinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: false, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + }) + wrapper + .findAll('.ds-menu-item') + .filter((item) => item.text() === 'post.menu.groupPin') + .at(0) + .trigger('click') + expect(wrapper.emitted('pinGroupPost')).toEqual([ + [ + { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: false, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + ], + ]) + }) + + it('unpin pinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: true, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + }) + wrapper + .findAll('.ds-menu-item') + .filter((item) => item.text() === 'post.menu.groupUnpin') + .at(0) + .trigger('click') + expect(wrapper.emitted('unpinGroupPost')).toEqual([ + [ + { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: true, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + ], + ]) + }) + }) + + describe('when currentlyPinnedPostsCount = 2', () => { + const currentlyPinnedPostsCount = 2 + + it('pin unpinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: false, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + }) + expect( + wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.groupPin') + .length, + ).toEqual(0) + }) + + it('unpin pinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: true, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + }) + wrapper + .findAll('.ds-menu-item') + .filter((item) => item.text() === 'post.menu.groupUnpin') + .at(0) + .trigger('click') + expect(wrapper.emitted('unpinGroupPost')).toEqual([ + [ + { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: true, + group: { + myRole, + currentlyPinnedPostsCount, + }, + }, + ], + ]) + }) + }) + }) + }) + + describe('as group usual', () => { + const myRole = 'usual' + + describe('when maxGroupPinnedPosts = 0', () => { + beforeEach(() => { + mocks.$env = { + MAX_GROUP_PINNED_POSTS: 0, + } + }) + + it('can not pin unpinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: false, + group: { + myRole, + }, + }, + }) + expect( + wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.groupPin'), + ).toHaveLength(0) + }) + + it('can not unpin pinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: true, + group: { + myRole, + }, + }, + }) + expect( + wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.groupUnpin'), + ).toHaveLength(0) + }) + }) + + describe('when maxPinnedPosts = 1', () => { + beforeEach(() => { + mocks.$env = { + MAX_GROUP_PINNED_POSTS: 1, + } + }) + + it('can not pin unpinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: false, + group: { + myRole, + currentlyPinnedPostsCount: 0, + }, + }, + }) + expect( + wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.groupPin'), + ).toHaveLength(0) + }) + + it('can not unpin pinned post', async () => { + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + groupPinned: true, + group: { + myRole, + currentlyPinnedPostsCount: 1, + }, + }, + }) + expect( + wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.groupUnpin'), + ).toHaveLength(0) + }) + }) + }) +}) diff --git a/webapp/components/ContentMenu/ContentMenu.vue b/webapp/components/ContentMenu/ContentMenu.vue index dc765c4a3..2b106a35d 100644 --- a/webapp/components/ContentMenu/ContentMenu.vue +++ b/webapp/components/ContentMenu/ContentMenu.vue @@ -244,6 +244,23 @@ export default { } } + if ( + this.resourceType === 'contribution' && + this.resource.group && + ['admin', 'owner'].includes(this.resource.group.myRole) && + (this.canBeGroupPinned || this.resource.groupPinned) + ) { + routes.push({ + label: this.resource.groupPinned + ? this.$t(`post.menu.groupUnpin`) + : this.$t(`post.menu.groupPin`), + callback: () => { + this.$emit(this.resource.groupPinned ? 'unpinGroupPost' : 'pinGroupPost', this.resource) + }, + icon: this.resource.groupPinned ? 'unlink' : 'link', + }) + } + return routes }, isModerator() { @@ -258,6 +275,15 @@ export default { (this.maxPinnedPosts > 1 && this.currentlyPinnedPosts < this.maxPinnedPosts) ) }, + canBeGroupPinned() { + const maxGroupPinnedPosts = this.$env.MAX_GROUP_PINNED_POSTS + return ( + maxGroupPinnedPosts === 1 || + (maxGroupPinnedPosts > 1 && + this.resource.group && + this.resource.group.currentlyPinnedPostsCount < maxGroupPinnedPosts) + ) + }, }, methods: { openItem(route, toggleMenu) { diff --git a/webapp/components/PostTeaser/PostTeaser.spec.js b/webapp/components/PostTeaser/PostTeaser.spec.js index 83b7570a0..b80c338a8 100644 --- a/webapp/components/PostTeaser/PostTeaser.spec.js +++ b/webapp/components/PostTeaser/PostTeaser.spec.js @@ -51,13 +51,17 @@ describe('PostTeaser', () => { } getters = { 'auth/isModerator': () => false, + 'auth/isAdmin': () => false, 'auth/user': () => { return {} }, 'categories/categoriesActive': () => false, + 'pinnedPosts/maxPinnedPosts': () => 0, + 'pinnedPosts/currentlyPinnedPosts': () => 0, } actions = { 'categories/init': jest.fn(), + 'pinnedPosts/fetch': jest.fn().mockResolvedValue(), } }) diff --git a/webapp/components/PostTeaser/PostTeaser.vue b/webapp/components/PostTeaser/PostTeaser.vue index 222df0a76..051ef61d1 100644 --- a/webapp/components/PostTeaser/PostTeaser.vue +++ b/webapp/components/PostTeaser/PostTeaser.vue @@ -112,6 +112,8 @@ :is-owner="isAuthor" @pinPost="pinPost" @unpinPost="unpinPost" + @pinGroupPost="pinGroupPost" + @unpinGroupPost="unpinGroupPost" @pushPost="pushPost" @unpushPost="unpushPost" @toggleObservePost="toggleObservePost" @@ -172,6 +174,10 @@ export default { type: Object, default: () => {}, }, + showGroupPinned: { + type: Boolean, + default: false, + }, }, mounted() { const { image } = this.post @@ -203,10 +209,11 @@ export default { ) }, isPinned() { - return this.post && this.post.pinned + return this.post && (this.post.pinned || (this.showGroupPinned && this.post.groupPinned)) }, ribbonText() { - if (this.post.pinned) return this.$t('post.pinned') + if (this.post && (this.post.pinned || (this.showGroupPinned && this.post.groupPinned))) + return this.$t('post.pinned') if (this.post.postType[0] === 'Event') return this.$t('post.event') return this.$t('post.name') }, @@ -229,6 +236,12 @@ export default { unpinPost(post) { this.$emit('unpinPost', post) }, + pinGroupPost(post) { + this.$emit('pinGroupPost', post) + }, + unpinGroupPost(post) { + this.$emit('unpinGroupPost', post) + }, pushPost(post) { this.$emit('pushPost', post) }, diff --git a/webapp/components/_new/features/SearchResults/SearchResults.spec.js b/webapp/components/_new/features/SearchResults/SearchResults.spec.js index 6e9141d5c..40bb0fcf7 100644 --- a/webapp/components/_new/features/SearchResults/SearchResults.spec.js +++ b/webapp/components/_new/features/SearchResults/SearchResults.spec.js @@ -15,11 +15,15 @@ const stubs = { } describe('SearchResults', () => { - let mocks, getters, actions, propsData, wrapper + let mocks, getters, propsData, wrapper + const Wrapper = () => { const store = new Vuex.Store({ getters, - actions, + actions: { + 'categories/init': jest.fn(), + 'pinnedPosts/fetch': jest.fn(), + }, }) return mount(SearchResults, { mocks, localVue, propsData, store, stubs }) } @@ -35,9 +39,6 @@ describe('SearchResults', () => { 'auth/isModerator': () => false, 'categories/categoriesActive': () => false, } - actions = { - 'categories/init': jest.fn(), - } propsData = { pageSize: 12, search: '', diff --git a/webapp/components/_new/features/SearchResults/SearchResults.vue b/webapp/components/_new/features/SearchResults/SearchResults.vue index 71f94a489..b43b12ca3 100644 --- a/webapp/components/_new/features/SearchResults/SearchResults.vue +++ b/webapp/components/_new/features/SearchResults/SearchResults.vue @@ -45,9 +45,12 @@ this.$toast.error(error.message)) }, + pinGroupPost(post, refetchPostList = () => {}) { + this.$apollo + .mutate({ + mutation: PostMutations().pinGroupPost, + variables: { + id: post.id, + }, + }) + .then(() => { + this.$toast.success(this.$t('post.menu.groupPinnedSuccessfully')) + // this.storePinGroupPost() + refetchPostList() + }) + .catch((error) => this.$toast.error(error.message)) + }, + unpinGroupPost(post, refetchPostList = () => {}) { + this.$apollo + .mutate({ + mutation: PostMutations().unpinGroupPost, + variables: { + id: post.id, + }, + }) + .then(() => { + this.$toast.success(this.$t('post.menu.groupUnpinnedSuccessfully')) + // this.storeUnpinGroupPost() + refetchPostList() + }) + .catch((error) => this.$toast.error(error.message)) + }, pushPost(post, refetchPostList = () => {}) { this.$apollo .mutate({ diff --git a/webapp/pages/groups/_id/_slug.vue b/webapp/pages/groups/_id/_slug.vue index c2fb2c1a9..2bd518260 100644 --- a/webapp/pages/groups/_id/_slug.vue +++ b/webapp/pages/groups/_id/_slug.vue @@ -69,7 +69,7 @@ { 'auth/user': { id: 'u23', }, + 'auth/isAdmin': () => false, + 'pinnedPosts/maxPinnedPosts': () => 0, + 'pinnedPosts/currentlyPinnedPosts': () => 0, }, + dispatch: jest.fn().mockResolvedValue(), } }) diff --git a/webapp/pages/profile/_id/_slug.vue b/webapp/pages/profile/_id/_slug.vue index 83b80b3af..1c0ad08ac 100644 --- a/webapp/pages/profile/_id/_slug.vue +++ b/webapp/pages/profile/_id/_slug.vue @@ -160,6 +160,8 @@ @removePostFromList="posts = removePostFromList(post, posts)" @pinPost="pinPost(post, refetchPostList)" @unpinPost="unpinPost(post, refetchPostList)" + @pinGroupPost="pinGroupPost(post, refetchPostList)" + @unpinGroupPost="unpinGroupPost(post, refetchPostList)" @pushPost="pushPost(post, refetchPostList)" @unpushPost="unpushPost(post, refetchPostList)" @toggleObservePost="