feat(backend): group pins (#9034)

Co-authored-by: Wolfgang Huß <wolle.huss@pjannto.com>
This commit is contained in:
Ulf Gebhardt 2026-01-28 16:53:29 +01:00 committed by GitHub
parent 524c4caf5e
commit 6fc3c03860
41 changed files with 1571 additions and 30 deletions

View File

@ -48,3 +48,4 @@ IMAGOR_SECRET=mysecret
CATEGORIES_ACTIVE=false
MAX_PINNED_POSTS=1
MAX_GROUP_PINNED_POSTS=1

View File

@ -40,3 +40,4 @@ IMAGOR_SECRET=mysecret
CATEGORIES_ACTIVE=false
MAX_PINNED_POSTS=1
MAX_GROUP_PINNED_POSTS=1

View File

@ -18,7 +18,7 @@ module.exports = {
],
coverageThreshold: {
global: {
lines: 93,
lines: 92,
},
},
testMatch: ['**/src/**/?(*.)+(spec|test).ts?(x)'],

View File

@ -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 = {

View File

@ -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',

View File

@ -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
}
}
`

View File

@ -11,6 +11,7 @@ export const profilePagePosts = gql`
id
title
content
groupPinned
}
}
`

View File

@ -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
}
}
`

View File

@ -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: {

View File

@ -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<Context['config']>
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 }),
],
},
})
})
})
})

View File

@ -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

View File

@ -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 {

View File

@ -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!

View File

@ -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,

View File

@ -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',

View File

@ -11,4 +11,5 @@ NETWORK_NAME="Ocelot.social"
ASK_FOR_REAL_NAME=false
REQUIRE_LOCATION=false
REQUIRE_LOCATION=false
MAX_GROUP_PINNED_POSTS=1

View File

@ -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: '<span><slot /></span>',
},
}
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)
})
})
})
})

View File

@ -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) {

View File

@ -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(),
}
})

View File

@ -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)
},

View File

@ -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: '',

View File

@ -45,9 +45,12 @@
<post-teaser
:post="post"
:width="{ base: '100%', md: '100%', xl: '50%' }"
:showGroupPinned="true"
@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="

View File

@ -38,6 +38,9 @@ const options = {
NETWORK_NAME: process.env.NETWORK_NAME || 'Ocelot.social',
ASK_FOR_REAL_NAME: process.env.ASK_FOR_REAL_NAME === 'true' || false,
REQUIRE_LOCATION: process.env.REQUIRE_LOCATION === 'true' || false,
MAX_GROUP_PINNED_POSTS: Number.isNaN(Number(process.env.MAX_GROUP_PINNED_POSTS))
? 1
: Number(process.env.MAX_GROUP_PINNED_POSTS),
}
const language = {

View File

@ -173,6 +173,40 @@ export default () => {
}
}
`,
pinGroupPost: gql`
mutation ($id: ID!) {
pinGroupPost(id: $id) {
id
title
slug
content
contentExcerpt
language
pinnedBy {
id
name
role
}
}
}
`,
unpinGroupPost: gql`
mutation ($id: ID!) {
unpinGroupPost(id: $id) {
id
title
slug
content
contentExcerpt
language
pinnedBy {
id
name
role
}
}
}
`,
pushPost: gql`
mutation ($id: ID!) {
pushPost(id: $id) {

View File

@ -47,12 +47,6 @@ export default (i18n) => {
...badges
}
}
group {
id
name
slug
groupType
}
}
}
`
@ -90,12 +84,6 @@ export const filterPosts = (i18n) => {
...location
...badges
}
group {
id
name
slug
groupType
}
}
}
`
@ -132,12 +120,6 @@ export const profilePagePosts = (i18n) => {
...location
...badges
}
group {
id
name
slug
groupType
}
}
}
`

View File

@ -27,7 +27,16 @@ export const post = gql`
}
pinnedAt
pinned
groupPinned
isObservedByMe
observingUsersCount
group {
id
name
slug
groupType
myRole
currentlyPinnedPostsCount
}
}
`

View File

@ -861,6 +861,10 @@
"menu": {
"delete": "Beitrag löschen",
"edit": "Beitrag bearbeiten",
"groupPin": "Beitrag anheften (Gruppe)",
"groupPinnedSuccessfully": "Beitrag erfolgreich angeheftet!",
"groupUnpin": "Beitrag loslösen (Gruppe)",
"groupUnpinnedSuccessfully": "Angehefteten Beitrag erfolgreich losgelöst!",
"observe": "Beitrag beobachten",
"observedSuccessfully": "Du beobachtest diesen Beitrag!",
"pin": "Beitrag anheften",

View File

@ -861,6 +861,10 @@
"menu": {
"delete": "Delete post",
"edit": "Edit post",
"groupPin": "Pin post (Group)",
"groupPinnedSuccessfully": "Post pinned successfully!",
"groupUnpin": "Unpin post (Group)",
"groupUnpinnedSuccessfully": "Post unpinned successfully!",
"observe": "Observe post",
"observedSuccessfully": "You are now observing this post!",
"pin": "Pin post",

View File

@ -861,6 +861,10 @@
"menu": {
"delete": "Borrar contribución",
"edit": "Editar contribución",
"groupPin": null,
"groupPinnedSuccessfully": null,
"groupUnpin": null,
"groupUnpinnedSuccessfully": null,
"observe": "Observar contribución",
"observedSuccessfully": null,
"pin": "Anclar contribución",

View File

@ -861,6 +861,10 @@
"menu": {
"delete": "Supprimer le Post",
"edit": "Modifier le Post",
"groupPin": null,
"groupPinnedSuccessfully": null,
"groupUnpin": null,
"groupUnpinnedSuccessfully": null,
"observe": "Observer le Post",
"observedSuccessfully": null,
"pin": "Épingler le Post",

View File

@ -861,6 +861,10 @@
"menu": {
"delete": null,
"edit": null,
"groupPin": null,
"groupPinnedSuccessfully": null,
"groupUnpin": null,
"groupUnpinnedSuccessfully": null,
"observe": null,
"observedSuccessfully": null,
"pin": null,

View File

@ -861,6 +861,10 @@
"menu": {
"delete": null,
"edit": null,
"groupPin": null,
"groupPinnedSuccessfully": null,
"groupUnpin": null,
"groupUnpinnedSuccessfully": null,
"observe": null,
"observedSuccessfully": null,
"pin": null,

View File

@ -861,6 +861,10 @@
"menu": {
"delete": "Usuń wpis",
"edit": "Edytuj wpis",
"groupPin": null,
"groupPinnedSuccessfully": null,
"groupUnpin": null,
"groupUnpinnedSuccessfully": null,
"observe": null,
"observedSuccessfully": null,
"pin": null,

View File

@ -861,6 +861,10 @@
"menu": {
"delete": "Excluir publicação",
"edit": "Editar publicação",
"groupPin": null,
"groupPinnedSuccessfully": null,
"groupUnpin": null,
"groupUnpinnedSuccessfully": null,
"observe": "Observar publicação",
"observedSuccessfully": null,
"pin": "Fixar publicação",

View File

@ -861,6 +861,10 @@
"menu": {
"delete": "Удалить пост",
"edit": "Редактировать пост",
"groupPin": null,
"groupPinnedSuccessfully": null,
"groupUnpin": null,
"groupUnpinnedSuccessfully": null,
"observe": null,
"observedSuccessfully": null,
"pin": "Закрепить пост",

View File

@ -38,6 +38,36 @@ export default {
})
.catch((error) => 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({

View File

@ -69,7 +69,7 @@
</base-button>
<!-- Group join / leave -->
<join-leave-button
:group="group || {}"
:group="group"
:userId="currentUser.id"
:isMember="isGroupMember"
:isNonePendingMember="isGroupMemberNonePending"
@ -243,9 +243,12 @@
<post-teaser
:post="post"
:width="{ base: '100%', md: '100%', xl: '50%' }"
:showGroupPinned="true"
@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="
@ -467,7 +470,7 @@ export default {
offset: this.offset,
filter: this.filter,
first: this.pageSize,
orderBy: 'sortDate_desc',
orderBy: ['groupPinned_asc', 'sortDate_desc'],
},
updateQuery: UpdateQuery(this, { $state, pageKey: 'profilePagePosts' }),
})
@ -576,7 +579,7 @@ export default {
filter: this.filter,
first: this.pageSize,
offset: 0,
orderBy: 'sortDate_desc',
orderBy: ['groupPinned_asc', 'sortDate_desc'],
}
},
update({ profilePagePosts }) {

View File

@ -116,6 +116,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="

View File

@ -58,6 +58,8 @@
:is-owner="isAuthor"
@pinPost="pinPost"
@unpinPost="unpinPost"
@pinGroupPost="pinGroupPost"
@unpinGroupPost="unpinGroupPost"
@pushPost="pushPost"
@unpushPost="unpushPost"
@toggleObservePost="toggleObservePost"

View File

@ -78,7 +78,11 @@ describe('ProfileSlug', () => {
'auth/user': {
id: 'u23',
},
'auth/isAdmin': () => false,
'pinnedPosts/maxPinnedPosts': () => 0,
'pinnedPosts/currentlyPinnedPosts': () => 0,
},
dispatch: jest.fn().mockResolvedValue(),
}
})

View File

@ -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="