feat(backend): pin more than one post (#8598)

* feat(backend): pin more than one post

* add postPinnedCount query, better names for env variable

* add store and mixin for pinned posts counts

* test pinned post store

* context menu for pin posts

* fix typos

* unpin posts is always possible

---------

Co-authored-by: Wolfgang Huß <wolle.huss@pjannto.com>
This commit is contained in:
Moriz Wahl 2025-05-28 19:12:27 +02:00 committed by GitHub
parent 903ce7071f
commit b736a2a2e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 935 additions and 312 deletions

View File

@ -47,3 +47,4 @@ AWS_REGION=
AWS_BUCKET=
CATEGORIES_ACTIVE=false
MAX_PINNED_POSTS=1

View File

@ -123,6 +123,9 @@ const options = {
INVITE_CODES_GROUP_PER_USER:
(env.INVITE_CODES_GROUP_PER_USER && parseInt(env.INVITE_CODES_GROUP_PER_USER)) || 7,
CATEGORIES_ACTIVE: process.env.CATEGORIES_ACTIVE === 'true' || false,
MAX_PINNED_POSTS: Number.isNaN(Number(process.env.MAX_PINNED_POSTS))
? 1
: Number(process.env.MAX_PINNED_POSTS),
}
const language = {

View File

@ -1096,8 +1096,20 @@ describe('pin posts', () => {
authenticatedUser = await admin.toJson()
})
describe('are allowed to pin posts', () => {
const postOrderingQuery = gql`
query ($orderBy: [_PostOrdering]) {
Post(orderBy: $orderBy) {
id
pinned
createdAt
pinnedAt
}
}
`
describe('MAX_PINNED_POSTS is 0', () => {
beforeEach(async () => {
CONFIG.MAX_PINNED_POSTS = 0
await Factory.build(
'post',
{
@ -1110,217 +1122,458 @@ describe('pin posts', () => {
variables = { ...variables, id: 'created-and-pinned-by-same-admin' }
})
it('responds with the updated Post', async () => {
const expected = {
data: {
pinPost: {
id: 'created-and-pinned-by-same-admin',
author: {
name: 'Admin',
},
pinnedBy: {
id: 'current-user',
name: 'Admin',
role: 'admin',
},
},
},
errors: undefined,
}
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject(
expected,
)
})
it('sets createdAt date for PINNED', async () => {
const expected = {
data: {
pinPost: {
id: 'created-and-pinned-by-same-admin',
pinnedAt: expect.any(String),
},
},
errors: undefined,
}
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject(
expected,
)
})
it('sets redundant `pinned` property for performant ordering', async () => {
variables = { ...variables, id: 'created-and-pinned-by-same-admin' }
const expected = {
data: { pinPost: { pinned: true } },
errors: undefined,
}
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject(
expected,
)
})
})
describe('post created by another admin', () => {
let otherAdmin
beforeEach(async () => {
otherAdmin = await Factory.build('user', {
role: 'admin',
name: 'otherAdmin',
it('throws with error that pinning posts is not allowed', async () => {
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject({
data: { pinPost: null },
errors: [{ message: 'Pinned posts are not allowed!' }],
})
authenticatedUser = await otherAdmin.toJson()
await Factory.build(
'post',
{
id: 'created-by-one-admin-pinned-by-different-one',
},
{
author: otherAdmin,
},
)
})
})
describe('MAX_PINNED_POSTS is 1', () => {
beforeEach(() => {
CONFIG.MAX_PINNED_POSTS = 1
})
it('responds with the updated Post', async () => {
authenticatedUser = await admin.toJson()
variables = { ...variables, id: 'created-by-one-admin-pinned-by-different-one' }
const expected = {
data: {
pinPost: {
describe('are allowed to pin posts', () => {
beforeEach(async () => {
await Factory.build(
'post',
{
id: 'created-and-pinned-by-same-admin',
},
{
author: admin,
},
)
variables = { ...variables, id: 'created-and-pinned-by-same-admin' }
})
it('responds with the updated Post', async () => {
const expected = {
data: {
pinPost: {
id: 'created-and-pinned-by-same-admin',
author: {
name: 'Admin',
},
pinnedBy: {
id: 'current-user',
name: 'Admin',
role: 'admin',
},
},
},
errors: undefined,
}
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject(
expected,
)
})
it('sets createdAt date for PINNED', async () => {
const expected = {
data: {
pinPost: {
id: 'created-and-pinned-by-same-admin',
pinnedAt: expect.any(String),
},
},
errors: undefined,
}
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject(
expected,
)
})
it('sets redundant `pinned` property for performant ordering', async () => {
variables = { ...variables, id: 'created-and-pinned-by-same-admin' }
const expected = {
data: { pinPost: { pinned: true } },
errors: undefined,
}
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject(
expected,
)
})
})
describe('post created by another admin', () => {
let otherAdmin
beforeEach(async () => {
otherAdmin = await Factory.build('user', {
role: 'admin',
name: 'otherAdmin',
})
authenticatedUser = await otherAdmin.toJson()
await Factory.build(
'post',
{
id: 'created-by-one-admin-pinned-by-different-one',
author: {
name: 'otherAdmin',
},
pinnedBy: {
id: 'current-user',
name: 'Admin',
role: 'admin',
},
{
author: otherAdmin,
},
)
})
it('responds with the updated Post', async () => {
authenticatedUser = await admin.toJson()
variables = { ...variables, id: 'created-by-one-admin-pinned-by-different-one' }
const expected = {
data: {
pinPost: {
id: 'created-by-one-admin-pinned-by-different-one',
author: {
name: 'otherAdmin',
},
pinnedBy: {
id: 'current-user',
name: 'Admin',
role: 'admin',
},
},
},
},
errors: undefined,
}
errors: undefined,
}
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject(
expected,
)
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject(
expected,
)
})
})
describe('post created by another user', () => {
it('responds with the updated Post', async () => {
const expected = {
data: {
pinPost: {
id: 'p9876',
author: {
slug: 'the-author',
},
pinnedBy: {
id: 'current-user',
name: 'Admin',
role: 'admin',
},
},
},
errors: undefined,
}
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject(
expected,
)
})
})
describe('pinned post already exists', () => {
let pinnedPost
beforeEach(async () => {
await Factory.build(
'post',
{
id: 'only-pinned-post',
},
{
author: admin,
},
)
await mutate({ mutation: pinPostMutation, variables })
})
it('removes previous `pinned` attribute', async () => {
const cypher = 'MATCH (post:Post) WHERE post.pinned IS NOT NULL RETURN post'
pinnedPost = await database.neode.cypher(cypher, {})
expect(pinnedPost.records).toHaveLength(1)
variables = { ...variables, id: 'only-pinned-post' }
await mutate({ mutation: pinPostMutation, variables })
pinnedPost = await database.neode.cypher(cypher, {})
expect(pinnedPost.records).toHaveLength(1)
})
it('removes previous PINNED relationship', async () => {
variables = { ...variables, id: 'only-pinned-post' }
await mutate({ mutation: pinPostMutation, variables })
pinnedPost = await database.neode.cypher(
`MATCH (:User)-[pinned:PINNED]->(post:Post) RETURN post, pinned`,
{},
)
expect(pinnedPost.records).toHaveLength(1)
})
})
describe('PostOrdering', () => {
beforeEach(async () => {
await Factory.build('post', {
id: 'im-a-pinned-post',
createdAt: '2019-11-22T17:26:29.070Z',
pinned: true,
})
await Factory.build('post', {
id: 'i-was-created-before-pinned-post',
// fairly old, so this should be 3rd
createdAt: '2019-10-22T17:26:29.070Z',
})
})
describe('order by `pinned_asc` and `createdAt_desc`', () => {
beforeEach(() => {
// this is the ordering in the frontend
variables = { orderBy: ['pinned_asc', 'createdAt_desc'] }
})
it('pinned post appear first even when created before other posts', async () => {
await expect(query({ query: postOrderingQuery, variables })).resolves.toMatchObject({
data: {
Post: [
{
id: 'im-a-pinned-post',
pinned: true,
createdAt: '2019-11-22T17:26:29.070Z',
pinnedAt: expect.any(String),
},
{
id: 'p9876',
pinned: null,
createdAt: expect.any(String),
pinnedAt: null,
},
{
id: 'i-was-created-before-pinned-post',
pinned: null,
createdAt: '2019-10-22T17:26:29.070Z',
pinnedAt: null,
},
],
},
errors: undefined,
})
})
})
})
})
describe('post created by another user', () => {
it('responds with the updated Post', async () => {
const expected = {
data: {
pinPost: {
id: 'p9876',
author: {
slug: 'the-author',
},
pinnedBy: {
id: 'current-user',
name: 'Admin',
role: 'admin',
},
},
},
errors: undefined,
}
describe('MAX_PINNED_POSTS = 3', () => {
const postsPinnedCountsQuery = `query { PostsPinnedCounts { maxPinnedPosts, currentlyPinnedPosts } }`
await expect(mutate({ mutation: pinPostMutation, variables })).resolves.toMatchObject(
expected,
)
})
})
describe('pinned post already exists', () => {
let pinnedPost
beforeEach(async () => {
CONFIG.MAX_PINNED_POSTS = 3
await Factory.build(
'post',
{
id: 'only-pinned-post',
id: 'first-post',
createdAt: '2019-10-22T17:26:29.070Z',
},
{
author: admin,
},
)
await mutate({ mutation: pinPostMutation, variables })
})
it('removes previous `pinned` attribute', async () => {
const cypher = 'MATCH (post:Post) WHERE post.pinned IS NOT NULL RETURN post'
pinnedPost = await database.neode.cypher(cypher, {})
expect(pinnedPost.records).toHaveLength(1)
variables = { ...variables, id: 'only-pinned-post' }
await mutate({ mutation: pinPostMutation, variables })
pinnedPost = await database.neode.cypher(cypher, {})
expect(pinnedPost.records).toHaveLength(1)
})
it('removes previous PINNED relationship', async () => {
variables = { ...variables, id: 'only-pinned-post' }
await mutate({ mutation: pinPostMutation, variables })
pinnedPost = await database.neode.cypher(
`MATCH (:User)-[pinned:PINNED]->(post:Post) RETURN post, pinned`,
{},
await Factory.build(
'post',
{
id: 'second-post',
createdAt: '2018-10-22T17:26:29.070Z',
},
{
author: admin,
},
)
await Factory.build(
'post',
{
id: 'third-post',
createdAt: '2017-10-22T17:26:29.070Z',
},
{
author: admin,
},
)
await Factory.build(
'post',
{
id: 'another-post',
},
{
author: admin,
},
)
expect(pinnedPost.records).toHaveLength(1)
})
})
describe('PostOrdering', () => {
beforeEach(async () => {
await Factory.build('post', {
id: 'im-a-pinned-post',
createdAt: '2019-11-22T17:26:29.070Z',
pinned: true,
})
await Factory.build('post', {
id: 'i-was-created-before-pinned-post',
// fairly old, so this should be 3rd
createdAt: '2019-10-22T17:26:29.070Z',
})
})
describe('order by `pinned_asc` and `createdAt_desc`', () => {
beforeEach(() => {
// this is the ordering in the frontend
variables = { orderBy: ['pinned_asc', 'createdAt_desc'] }
describe('first post', () => {
let result
beforeEach(async () => {
variables = { ...variables, id: 'first-post' }
result = await mutate({ mutation: pinPostMutation, variables })
})
it('pinned post appear first even when created before other posts', async () => {
const postOrderingQuery = gql`
query ($orderBy: [_PostOrdering]) {
Post(orderBy: $orderBy) {
id
pinned
createdAt
pinnedAt
}
}
`
await expect(query({ query: postOrderingQuery, variables })).resolves.toMatchObject({
it('pins the first post', () => {
expect(result).toMatchObject({
data: {
Post: [
{
id: 'im-a-pinned-post',
pinned: true,
createdAt: '2019-11-22T17:26:29.070Z',
pinnedAt: expect.any(String),
pinPost: {
id: 'first-post',
pinned: true,
pinnedAt: expect.any(String),
pinnedBy: {
id: 'current-user',
},
{
id: 'p9876',
pinned: null,
createdAt: expect.any(String),
pinnedAt: null,
},
{
id: 'i-was-created-before-pinned-post',
pinned: null,
createdAt: '2019-10-22T17:26:29.070Z',
pinnedAt: null,
},
],
},
},
errors: undefined,
})
})
it('returns the correct counts', async () => {
await expect(
query({
query: postsPinnedCountsQuery,
}),
).resolves.toMatchObject({
data: {
PostsPinnedCounts: {
maxPinnedPosts: 3,
currentlyPinnedPosts: 1,
},
},
})
})
describe('second post', () => {
beforeEach(async () => {
variables = { ...variables, id: 'second-post' }
result = await mutate({ mutation: pinPostMutation, variables })
})
it('pins the second post', () => {
expect(result).toMatchObject({
data: {
pinPost: {
id: 'second-post',
pinned: true,
pinnedAt: expect.any(String),
pinnedBy: {
id: 'current-user',
},
},
},
})
})
it('returns the correct counts', async () => {
await expect(
query({
query: postsPinnedCountsQuery,
}),
).resolves.toMatchObject({
data: {
PostsPinnedCounts: {
maxPinnedPosts: 3,
currentlyPinnedPosts: 2,
},
},
})
})
describe('third post', () => {
beforeEach(async () => {
variables = { ...variables, id: 'third-post' }
result = await mutate({ mutation: pinPostMutation, variables })
})
it('pins the second post', () => {
expect(result).toMatchObject({
data: {
pinPost: {
id: 'third-post',
pinned: true,
pinnedAt: expect.any(String),
pinnedBy: {
id: 'current-user',
},
},
},
})
})
it('returns the correct counts', async () => {
await expect(
query({
query: postsPinnedCountsQuery,
}),
).resolves.toMatchObject({
data: {
PostsPinnedCounts: {
maxPinnedPosts: 3,
currentlyPinnedPosts: 3,
},
},
})
})
describe('another post', () => {
beforeEach(async () => {
variables = { ...variables, id: 'another-post' }
result = await mutate({ mutation: pinPostMutation, variables })
})
it('throws with max pinned posts is reached', () => {
expect(result).toMatchObject({
data: { pinPost: null },
errors: [{ message: 'Max number of pinned posts is reached!' }],
})
})
})
describe('post ordering', () => {
beforeEach(() => {
// this is the ordering in the frontend
variables = { orderBy: ['pinned_asc', 'createdAt_desc'] }
})
it('places the pinned posts first, though they are much older', async () => {
await expect(query({ query: postOrderingQuery, variables })).resolves.toMatchObject(
{
data: {
Post: [
{
id: 'first-post',
pinned: true,
pinnedAt: expect.any(String),
createdAt: '2019-10-22T17:26:29.070Z',
},
{
id: 'second-post',
pinned: true,
pinnedAt: expect.any(String),
createdAt: '2018-10-22T17:26:29.070Z',
},
{
id: 'third-post',
pinned: true,
pinnedAt: expect.any(String),
createdAt: '2017-10-22T17:26:29.070Z',
},
{
id: 'another-post',
pinned: null,
pinnedAt: null,
createdAt: expect.any(String),
},
{
id: 'p9876',
pinned: null,
pinnedAt: null,
createdAt: expect.any(String),
},
],
},
errors: undefined,
},
)
})
})
})
})
})

View File

@ -10,6 +10,7 @@ import { neo4jgraphql } from 'neo4j-graphql-js'
import { v4 as uuid } from 'uuid'
import CONFIG from '@config/index'
import { Context } from '@src/server'
import { validateEventParams } from './helpers/events'
import { filterForMutedUsers } from './helpers/filterForMutedUsers'
@ -96,6 +97,17 @@ export default {
session.close()
}
},
PostsPinnedCounts: async (_object, params, context: Context, _resolveInfo) => {
const [postsPinnedCount] = (
await context.database.query({
query: 'MATCH (p:Post { pinned: true }) RETURN COUNT (p) AS count',
})
).records.map((r) => Number(r.get('count').toString()))
return {
maxPinnedPosts: CONFIG.MAX_PINNED_POSTS,
currentlyPinnedPosts: postsPinnedCount,
}
},
},
Mutation: {
CreatePost: async (_parent, params, context, _resolveInfo) => {
@ -330,56 +342,79 @@ export default {
session.close()
}
},
pinPost: async (_parent, params, context, _resolveInfo) => {
pinPost: async (_parent, params, context: Context, _resolveInfo) => {
if (CONFIG.MAX_PINNED_POSTS === 0) throw new Error('Pinned posts are not allowed!')
let pinnedPostWithNestedAttributes
const { driver, user } = context
const session = driver.session()
const { id: userId } = user
let writeTxResultPromise = session.writeTransaction(async (transaction) => {
const deletePreviousRelationsResponse = await transaction.run(
`
const pinPostCypher = `
MATCH (user:User {id: $userId}) WHERE user.role = 'admin'
MATCH (post:Post {id: $params.id})
WHERE NOT((post)-[:IN]->(:Group))
MERGE (user)-[pinned:PINNED {createdAt: toString(datetime())}]->(post)
SET post.pinned = true
RETURN post, pinned.createdAt as pinnedAt`
if (CONFIG.MAX_PINNED_POSTS === 1) {
let writeTxResultPromise = session.writeTransaction(async (transaction) => {
const deletePreviousRelationsResponse = await transaction.run(
`
MATCH (:User)-[previousRelations:PINNED]->(post:Post)
REMOVE post.pinned
DELETE previousRelations
RETURN post
`,
)
return deletePreviousRelationsResponse.records.map(
(record) => record.get('post').properties,
)
})
try {
await writeTxResultPromise
writeTxResultPromise = session.writeTransaction(async (transaction) => {
const pinPostTransactionResponse = await transaction.run(
`
MATCH (user:User {id: $userId}) WHERE user.role = 'admin'
MATCH (post:Post {id: $params.id})
WHERE NOT((post)-[:IN]->(:Group))
MERGE (user)-[pinned:PINNED {createdAt: toString(datetime())}]->(post)
SET post.pinned = true
RETURN post, pinned.createdAt as pinnedAt
`,
{ userId, params },
)
return pinPostTransactionResponse.records.map((record) => ({
pinnedPost: record.get('post').properties,
pinnedAt: record.get('pinnedAt'),
}))
return deletePreviousRelationsResponse.records.map(
(record) => record.get('post').properties,
)
})
const [transactionResult] = await writeTxResultPromise
if (transactionResult) {
const { pinnedPost, pinnedAt } = transactionResult
pinnedPostWithNestedAttributes = {
...pinnedPost,
pinnedAt,
try {
await writeTxResultPromise
writeTxResultPromise = session.writeTransaction(async (transaction) => {
const pinPostTransactionResponse = await transaction.run(pinPostCypher, {
userId,
params,
})
return pinPostTransactionResponse.records.map((record) => ({
pinnedPost: record.get('post').properties,
pinnedAt: record.get('pinnedAt'),
}))
})
const [transactionResult] = await writeTxResultPromise
if (transactionResult) {
const { pinnedPost, pinnedAt } = transactionResult
pinnedPostWithNestedAttributes = {
...pinnedPost,
pinnedAt,
}
}
} finally {
await session.close()
}
} finally {
session.close()
return pinnedPostWithNestedAttributes
} else {
const [currentPinnedPostCount] = (
await context.database.query({
query: `MATCH (:User)-[:PINNED]->(post:Post { pinned: true }) RETURN COUNT(post) AS count`,
})
).records.map((r) => Number(r.get('count').toString()))
if (currentPinnedPostCount >= CONFIG.MAX_PINNED_POSTS) {
throw new Error('Max number of pinned posts is reached!')
}
const [pinPostResult] = (
await context.database.write({
query: pinPostCypher,
variables: { userId, params },
})
).records.map((r) => ({
...r.get('post').properties,
pinnedAt: r.get('pinnedAt'),
}))
return pinPostResult
}
return pinnedPostWithNestedAttributes
},
unpinPost: async (_parent, params, context, _resolveInfo) => {
let unpinnedPost

View File

@ -250,6 +250,11 @@ type Mutation {
toggleObservePost(id: ID!, value: Boolean!): Post!
}
type PinnedPostCounts {
maxPinnedPosts: Int!
currentlyPinnedPosts: Int!
}
type Query {
Post(
id: ID
@ -271,4 +276,5 @@ type Query {
PostsEmotionsCountByEmotion(postId: ID!, data: _EMOTEDInput!): Int!
PostsEmotionsByCurrentUser(postId: ID!): [String]
profilePagePosts(filter: _PostFilter, first: Int, offset: Int, orderBy: [_PostOrdering]): [Post]
PostsPinnedCounts: PinnedPostCounts!
}

View File

@ -433,6 +433,7 @@ export default shield(
Room: isAuthenticated,
Message: isAuthenticated,
UnreadRooms: isAuthenticated,
PostsPinnedCounts: isAdmin,
// Invite Code
validateInviteCode: allow,

View File

@ -16,7 +16,10 @@ const stubs = {
},
}
let getters, mutations, mocks, menuToggle, openModalSpy
let getters, mutations, actions, mocks, menuToggle, openModalSpy
const maxPinnedPostsMock = jest.fn()
const currentlyPinnedPostsMock = jest.fn()
describe('ContentMenu.vue', () => {
beforeEach(() => {
@ -38,10 +41,15 @@ describe('ContentMenu.vue', () => {
getters = {
'auth/isModerator': () => false,
'auth/isAdmin': () => false,
'pinnedPosts/maxPinnedPosts': maxPinnedPostsMock,
'pinnedPosts/currentlyPinnedPosts': currentlyPinnedPostsMock,
}
actions = {
'pinnedPosts/fetch': jest.fn(),
}
const openContentMenu = async (values = {}) => {
const store = new Vuex.Store({ mutations, getters })
const store = new Vuex.Store({ mutations, getters, actions })
const wrapper = mount(ContentMenu, {
propsData: {
...values,
@ -91,53 +99,208 @@ describe('ContentMenu.vue', () => {
})
describe('admin can', () => {
it('pin unpinned post', async () => {
getters['auth/isAdmin'] = () => true
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
pinnedBy: null,
},
describe('when maxPinnedPosts = 0', () => {
beforeEach(() => {
maxPinnedPostsMock.mockReturnValue(0)
})
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.pin')
.at(0)
.trigger('click')
expect(wrapper.emitted('pinPost')).toEqual([
[
{
it('not pin unpinned post', async () => {
getters['auth/isAdmin'] = () => true
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
pinnedBy: null,
},
],
])
})
it('unpin pinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
pinnedBy: 'someone',
},
})
expect(
wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.pin'),
).toHaveLength(0)
})
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.unpin')
.at(0)
.trigger('click')
expect(wrapper.emitted('unpinPost')).toEqual([
[
{
it('unpin pinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
pinnedBy: 'someone',
},
],
])
})
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.unpin')
.at(0)
.trigger('click')
expect(wrapper.emitted('unpinPost')).toEqual([
[
{
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
pinnedBy: 'someone',
},
],
])
})
})
describe('when maxPinnedPosts = 1', () => {
beforeEach(() => {
maxPinnedPostsMock.mockReturnValue(1)
})
it('pin unpinned post', async () => {
getters['auth/isAdmin'] = () => true
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
pinnedBy: null,
},
})
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.pin')
.at(0)
.trigger('click')
expect(wrapper.emitted('pinPost')).toEqual([
[
{
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
pinnedBy: null,
},
],
])
})
it('unpin pinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
pinnedBy: 'someone',
},
})
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.unpin')
.at(0)
.trigger('click')
expect(wrapper.emitted('unpinPost')).toEqual([
[
{
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
pinnedBy: 'someone',
},
],
])
})
})
describe('when maxPinnedPosts = 3', () => {
describe('and max is not reached', () => {
beforeEach(() => {
maxPinnedPostsMock.mockReturnValue(3)
currentlyPinnedPostsMock.mockReturnValue(2)
})
it('pin unpinned post', async () => {
getters['auth/isAdmin'] = () => true
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
pinnedBy: null,
},
})
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.pin')
.at(0)
.trigger('click')
expect(wrapper.emitted('pinPost')).toEqual([
[
{
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
pinnedBy: null,
},
],
])
})
it('unpin pinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
pinnedBy: 'someone',
},
})
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.unpin')
.at(0)
.trigger('click')
expect(wrapper.emitted('unpinPost')).toEqual([
[
{
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
pinnedBy: 'someone',
},
],
])
})
})
describe('and max is reached', () => {
beforeEach(() => {
maxPinnedPostsMock.mockReturnValue(3)
currentlyPinnedPostsMock.mockReturnValue(3)
})
it('not pin unpinned post', async () => {
getters['auth/isAdmin'] = () => true
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
pinnedBy: null,
},
})
expect(
wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.pin'),
).toHaveLength(0)
})
it('unpin pinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
pinnedBy: 'someone',
},
})
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.unpin')
.at(0)
.trigger('click')
expect(wrapper.emitted('unpinPost')).toEqual([
[
{
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
pinnedBy: 'someone',
},
],
])
})
})
})
it('can delete another user', async () => {

View File

@ -32,12 +32,14 @@
<script>
import Dropdown from '~/components/Dropdown'
import PinnedPostsMixin from '~/mixins/pinnedPosts'
export default {
name: 'ContentMenu',
components: {
Dropdown,
},
mixins: [PinnedPostsMixin],
props: {
placement: { type: String, default: 'top-end' },
resource: { type: Object, required: true },
@ -81,7 +83,7 @@ export default {
}
if (this.isAdmin && !this.resource.group) {
if (!this.resource.pinnedBy) {
if (!this.resource.pinnedBy && this.canBePinned) {
routes.push({
label: this.$t(`post.menu.pin`),
callback: () => {
@ -90,13 +92,15 @@ export default {
icon: 'link',
})
} else {
routes.push({
label: this.$t(`post.menu.unpin`),
callback: () => {
this.$emit('unpinPost', this.resource)
},
icon: 'unlink',
})
if (this.resource.pinnedBy) {
routes.push({
label: this.$t(`post.menu.unpin`),
callback: () => {
this.$emit('unpinPost', this.resource)
},
icon: 'unlink',
})
}
}
}
@ -228,6 +232,12 @@ export default {
isAdmin() {
return this.$store.getters['auth/isAdmin']
},
canBePinned() {
return (
this.maxPinnedPosts === 1 ||
(this.maxPinnedPosts > 1 && this.currentlyPinnedPosts < this.maxPinnedPosts)
)
},
},
methods: {
openItem(route, toggleMenu) {

View File

@ -187,3 +187,14 @@ export const relatedContributions = (i18n) => {
}
`
}
export const postsPinnedCountsQuery = () => {
return gql`
query {
PostsPinnedCounts {
maxPinnedPosts
currentlyPinnedPosts
}
}
`
}

View File

@ -0,0 +1,19 @@
import { mapGetters, mapActions } from 'vuex'
export default {
computed: {
...mapGetters({
maxPinnedPosts: 'pinnedPosts/maxPinnedPosts',
currentlyPinnedPosts: 'pinnedPosts/currentlyPinnedPosts',
isAdmin: 'auth/isAdmin',
}),
},
methods: {
...mapActions({
fetchPinnedPostsCount: 'pinnedPosts/fetch',
}),
},
async created() {
if (this.isAdmin && this.maxPinnedPosts === 0) await this.fetchPinnedPostsCount()
},
}

View File

@ -1,4 +1,5 @@
import PostMutations from '~/graphql/PostMutations'
import { mapMutations } from 'vuex'
export default {
methods: {
@ -17,6 +18,7 @@ export default {
})
.then(() => {
this.$toast.success(this.$t('post.menu.pinnedSuccessfully'))
this.storePinPost()
refetchPostList()
})
.catch((error) => this.$toast.error(error.message))
@ -31,6 +33,7 @@ export default {
})
.then(() => {
this.$toast.success(this.$t('post.menu.unpinnedSuccessfully'))
this.storeUnpinPost()
refetchPostList()
})
.catch((error) => this.$toast.error(error.message))
@ -53,5 +56,9 @@ export default {
})
.catch((error) => this.$toast.error(error.message))
},
...mapMutations({
storePinPost: 'pinnedPosts/pinPost',
storeUnpinPost: 'pinnedPosts/unpinPost',
}),
},
}

View File

@ -179,10 +179,10 @@ import {
} from '~/components/utils/PostHelpers'
import PostQuery from '~/graphql/PostQuery'
import { groupQuery } from '~/graphql/groups'
import PostMutations from '~/graphql/PostMutations'
import links from '~/constants/links.js'
import SortCategories from '~/mixins/sortCategoriesMixin.js'
import GetCategories from '~/mixins/getCategoriesMixin.js'
import postListActions from '~/mixins/postListActions'
import SortCategories from '~/mixins/sortCategoriesMixin.js'
export default {
name: 'PostSlug',
@ -204,7 +204,7 @@ export default {
PageParamsLink,
UserTeaser,
},
mixins: [SortCategories, GetCategories],
mixins: [GetCategories, postListActions, SortCategories],
head() {
return {
title: this.title,
@ -320,46 +320,6 @@ export default {
this.post.isObservedByMe = comment.isPostObservedByMe
this.post.observingUsersCount = comment.postObservingUsersCount
},
pinPost(post) {
this.$apollo
.mutate({
mutation: PostMutations().pinPost,
variables: { id: post.id },
})
.then(() => {
this.$toast.success(this.$t('post.menu.pinnedSuccessfully'))
})
.catch((error) => this.$toast.error(error.message))
},
unpinPost(post) {
this.$apollo
.mutate({
mutation: PostMutations().unpinPost,
variables: { id: post.id },
})
.then(() => {
this.$toast.success(this.$t('post.menu.unpinnedSuccessfully'))
})
.catch((error) => this.$toast.error(error.message))
},
toggleObservePost(postId, value) {
this.$apollo
.mutate({
mutation: PostMutations().toggleObservePost,
variables: {
value,
id: postId,
},
})
.then(() => {
const message = this.$t(
`post.menu.${value ? 'observedSuccessfully' : 'unobservedSuccessfully'}`,
)
this.$toast.success(message)
this.$apollo.queries.Post.refetch()
})
.catch((error) => this.$toast.error(error.message))
},
toggleNewCommentForm(showNewCommentForm) {
this.showNewCommentForm = showNewCommentForm
},

View File

@ -0,0 +1,43 @@
import { postsPinnedCountsQuery } from '~/graphql/PostQuery'
export const state = () => {
return {
maxPinnedPosts: 0,
currentlyPinnedPosts: 0,
}
}
export const mutations = {
pinPost(state) {
state.currentlyPinnedPosts++
},
unpinPost(state) {
state.currentlyPinnedPosts--
},
setMaxPinnedPosts(state, value) {
state.maxPinnedPosts = value
},
setCurrentlyPinnedPosts(state, value) {
state.currentlyPinnedPosts = value
},
}
export const getters = {
maxPinnedPosts(state) {
return state.maxPinnedPosts
},
currentlyPinnedPosts(state) {
return state.currentlyPinnedPosts
},
}
export const actions = {
async fetch({ commit }) {
const client = this.app.apolloProvider.defaultClient
const {
data: { PostsPinnedCounts },
} = await client.query({ query: postsPinnedCountsQuery() })
commit('setMaxPinnedPosts', PostsPinnedCounts.maxPinnedPosts)
commit('setCurrentlyPinnedPosts', PostsPinnedCounts.currentlyPinnedPosts)
},
}

View File

@ -0,0 +1,111 @@
import { state as initialState, mutations, getters, actions } from './pinnedPosts'
import { postsPinnedCountsQuery } from '~/graphql/PostQuery'
describe('pinned post store', () => {
describe('initial state', () => {
it('sets all values to 0', () => {
expect(initialState()).toEqual({
maxPinnedPosts: 0,
currentlyPinnedPosts: 0,
})
})
})
describe('mutations', () => {
let testMutation
const state = {
maxPinnedPosts: 0,
currentlyPinnedPosts: 0,
}
describe('pinPost', () => {
it('increments currentlyPinnedPosts', () => {
testMutation = () => {
mutations.pinPost(state)
return getters.currentlyPinnedPosts(state)
}
expect(testMutation()).toBe(1)
})
})
describe('unpinPost', () => {
it('decrements currentlyPinnedPosts', () => {
state.currentlyPinnedPosts = 2
testMutation = () => {
mutations.unpinPost(state)
return getters.currentlyPinnedPosts(state)
}
expect(testMutation()).toBe(1)
})
})
describe('setMaxPinnedPosts', () => {
it('sets maxPinnedPosts correctly', () => {
state.maxPinnedPosts = 3
testMutation = () => {
mutations.setMaxPinnedPosts(state, 1)
return getters.maxPinnedPosts(state)
}
expect(testMutation()).toBe(1)
})
})
describe('setCurrentlyPinnedPosts', () => {
it('sets currentlyPinnedPosts', () => {
state.currentlyPinnedPosts = 3
testMutation = () => {
mutations.setCurrentlyPinnedPosts(state, 1)
return getters.currentlyPinnedPosts(state)
}
expect(testMutation()).toBe(1)
})
})
})
describe('actions', () => {
const queryMock = jest.fn().mockResolvedValue({
data: {
PostsPinnedCounts: {
maxPinnedPosts: 3,
currentlyPinnedPosts: 2,
},
},
})
const commit = jest.fn()
let action
beforeEach(() => {
const module = {
app: {
apolloProvider: {
defaultClient: {
query: queryMock,
},
},
},
}
action = actions.fetch.bind(module)
})
describe('fetch', () => {
beforeEach(async () => {
await action({ commit })
})
it('calls apollo', () => {
expect(queryMock).toBeCalledWith({
query: postsPinnedCountsQuery(),
})
})
it('commits setMaxPinnedPosts', () => {
expect(commit).toBeCalledWith('setMaxPinnedPosts', 3)
})
it('commits setCurrentlyPinnedPosts', () => {
expect(commit).toBeCalledWith('setCurrentlyPinnedPosts', 2)
})
})
})
})