From e87a33eb3f40aadb17dcd7eace14b164a7ec49fb Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Tue, 3 Jun 2025 17:57:21 +0200 Subject: [PATCH] feat(backend): push posts (#8609) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * push posts push posts * unpush posts * fix comment query * locales * fix locales * fix tests * Update webapp/locales/de.json Co-authored-by: Wolfgang Huß * Update webapp/locales/de.json Co-authored-by: Wolfgang Huß * Update webapp/locales/de.json Co-authored-by: Wolfgang Huß * fix unpushedSuccessfully english message * remove paremeters from unpushPost * rename pushPostToTop -> pushPost, tests * update locales & tests webapp * fix lint --------- Co-authored-by: Wolfgang Huß --- .../20250530140506-post-sort-date.ts | 53 +++ backend/src/db/models/Post.ts | 1 + backend/src/graphql/queries/Post.ts | 12 + backend/src/graphql/queries/pushPost.ts | 9 + backend/src/graphql/queries/unpushPost.ts | 9 + backend/src/graphql/resolvers/posts.spec.ts | 365 +++++++++++++++--- backend/src/graphql/resolvers/posts.ts | 35 ++ backend/src/graphql/types/type/Post.gql | 5 + backend/src/middleware/orderByMiddleware.ts | 2 +- .../src/middleware/permissionsMiddleware.ts | 2 + .../ContentMenu/ContentMenu.spec.js | 76 ++++ webapp/components/ContentMenu/ContentMenu.vue | 20 + .../components/FilterMenu/FilterMenu.spec.js | 2 +- .../FilterMenu/OrderByFilter.spec.js | 12 +- .../components/FilterMenu/OrderByFilter.vue | 4 +- webapp/components/PostTeaser/PostTeaser.vue | 8 + .../features/SearchResults/SearchResults.vue | 2 + webapp/graphql/Fragments.js | 1 + webapp/graphql/PostMutations.js | 34 ++ webapp/locales/de.json | 6 +- webapp/locales/en.json | 6 +- webapp/locales/es.json | 6 +- webapp/locales/fr.json | 6 +- webapp/locales/it.json | 6 +- webapp/locales/nl.json | 6 +- webapp/locales/pl.json | 6 +- webapp/locales/pt.json | 6 +- webapp/locales/ru.json | 6 +- webapp/mixins/postListActions.js | 28 ++ webapp/pages/groups/_id/_slug.vue | 6 +- webapp/pages/index.spec.js | 2 +- webapp/pages/index.vue | 2 + webapp/pages/post/_id/_slug/index.vue | 2 + webapp/pages/profile/_id/_slug.vue | 6 +- webapp/store/posts.js | 8 +- webapp/store/posts.spec.js | 12 +- 36 files changed, 688 insertions(+), 84 deletions(-) create mode 100644 backend/src/db/migrations/20250530140506-post-sort-date.ts create mode 100644 backend/src/graphql/queries/Post.ts create mode 100644 backend/src/graphql/queries/pushPost.ts create mode 100644 backend/src/graphql/queries/unpushPost.ts diff --git a/backend/src/db/migrations/20250530140506-post-sort-date.ts b/backend/src/db/migrations/20250530140506-post-sort-date.ts new file mode 100644 index 000000000..a0806070d --- /dev/null +++ b/backend/src/db/migrations/20250530140506-post-sort-date.ts @@ -0,0 +1,53 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +import { getDriver } from '@db/neo4j' + +export const description = '' + +export async function up(_next) { + const driver = getDriver() + const session = driver.session() + const transaction = session.beginTransaction() + + try { + // Implement your migration here. + await transaction.run(` + MATCH (p:Post) + SET p.sortDate = p.createdAt + `) + await transaction.commit() + } catch (error) { + // eslint-disable-next-line no-console + console.log(error) + await transaction.rollback() + // eslint-disable-next-line no-console + console.log('rolled back') + throw new Error(error) + } finally { + await session.close() + } +} + +export async function down(_next) { + const driver = getDriver() + const session = driver.session() + const transaction = session.beginTransaction() + + try { + // Implement your migration here. + await transaction.run(` + MATCH (p:Post) + REMOVE p.sortDate + `) + await transaction.commit() + } catch (error) { + // eslint-disable-next-line no-console + console.log(error) + await transaction.rollback() + // eslint-disable-next-line no-console + console.log('rolled back') + throw new Error(error) + } finally { + await session.close() + } +} diff --git a/backend/src/db/models/Post.ts b/backend/src/db/models/Post.ts index 75081b728..6697faa30 100644 --- a/backend/src/db/models/Post.ts +++ b/backend/src/db/models/Post.ts @@ -45,6 +45,7 @@ export default { required: true, default: () => new Date().toISOString(), }, + sortDate: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, language: { type: 'string', allow: [null] }, comments: { type: 'relationship', diff --git a/backend/src/graphql/queries/Post.ts b/backend/src/graphql/queries/Post.ts new file mode 100644 index 000000000..f737bac86 --- /dev/null +++ b/backend/src/graphql/queries/Post.ts @@ -0,0 +1,12 @@ +import gql from 'graphql-tag' + +export const Post = gql` + query ($orderBy: [_PostOrdering]) { + Post(orderBy: $orderBy) { + id + pinned + createdAt + pinnedAt + } + } +` diff --git a/backend/src/graphql/queries/pushPost.ts b/backend/src/graphql/queries/pushPost.ts new file mode 100644 index 000000000..56568188a --- /dev/null +++ b/backend/src/graphql/queries/pushPost.ts @@ -0,0 +1,9 @@ +import gql from 'graphql-tag' + +export const pushPost = gql` + mutation pushPost($id: ID!) { + pushPost(id: $id) { + id + } + } +` diff --git a/backend/src/graphql/queries/unpushPost.ts b/backend/src/graphql/queries/unpushPost.ts new file mode 100644 index 000000000..dcf3ac0c8 --- /dev/null +++ b/backend/src/graphql/queries/unpushPost.ts @@ -0,0 +1,9 @@ +import gql from 'graphql-tag' + +export const unpushPost = gql` + mutation unpushPost($id: ID!) { + unpushPost(id: $id) { + id + } + } +` diff --git a/backend/src/graphql/resolvers/posts.spec.ts b/backend/src/graphql/resolvers/posts.spec.ts index dead7a876..7f679d2b9 100644 --- a/backend/src/graphql/resolvers/posts.spec.ts +++ b/backend/src/graphql/resolvers/posts.spec.ts @@ -12,6 +12,9 @@ import Factory, { cleanDatabase } from '@db/factories' import Image from '@db/models/Image' import { createGroupMutation } from '@graphql/queries/createGroupMutation' import { createPostMutation } from '@graphql/queries/createPostMutation' +import { Post } from '@graphql/queries/Post' +import { pushPost } from '@graphql/queries/pushPost' +import { unpushPost } from '@graphql/queries/unpushPost' import createServer, { getContext } from '@src/server' CONFIG.CATEGORIES_ACTIVE = true @@ -1009,6 +1012,281 @@ describe('UpdatePost', () => { }) }) +describe('push posts', () => { + let author + beforeEach(async () => { + author = await Factory.build('user', { slug: 'the-author' }) + await Factory.build( + 'post', + { + id: 'pFirst', + }, + { + author, + categoryIds, + }, + ) + await Factory.build( + 'post', + { + id: 'pSecond', + }, + { + author, + categoryIds, + }, + ) + await Factory.build( + 'post', + { + id: 'pThird', + }, + { + author, + categoryIds, + }, + ) + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + authenticatedUser = null + await expect( + mutate({ mutation: pushPost, variables: { id: 'pSecond' } }), + ).resolves.toMatchObject({ + errors: [{ message: 'Not Authorized!' }], + data: null, + }) + }) + }) + + describe('ordinary users', () => { + it('throws authorization error', async () => { + await expect( + mutate({ mutation: pushPost, variables: { id: 'pSecond' } }), + ).resolves.toMatchObject({ + errors: [{ message: 'Not Authorized!' }], + data: null, + }) + }) + }) + + describe('moderators', () => { + let moderator + beforeEach(async () => { + moderator = await user.update({ role: 'moderator', updatedAt: new Date().toISOString() }) + authenticatedUser = await moderator.toJson() + }) + + it('throws authorization error', async () => { + await expect( + mutate({ mutation: pushPost, variables: { id: 'pSecond' } }), + ).resolves.toMatchObject({ + errors: [{ message: 'Not Authorized!' }], + data: null, + }) + }) + }) + + describe('admins', () => { + let admin + beforeEach(async () => { + admin = await Factory.build('user', { + id: 'admin', + role: 'admin', + }) + authenticatedUser = await admin.toJson() + }) + + it('pushes the post to the front of the feed', async () => { + await expect( + query({ query: Post, variables: { orderBy: ['sortDate_desc'] } }), + ).resolves.toMatchObject({ + errors: undefined, + data: { + Post: [ + { + id: 'pThird', + }, + { + id: 'pSecond', + }, + { + id: 'pFirst', + }, + ], + }, + }) + await expect( + mutate({ mutation: pushPost, variables: { id: 'pSecond' } }), + ).resolves.toMatchObject({ + errors: undefined, + data: { + pushPost: { + id: 'pSecond', + }, + }, + }) + await expect( + query({ query: Post, variables: { orderBy: ['sortDate_desc'] } }), + ).resolves.toMatchObject({ + errors: undefined, + data: { + Post: [ + { + id: 'pSecond', + }, + { + id: 'pThird', + }, + { + id: 'pFirst', + }, + ], + }, + }) + }) + }) +}) + +describe('unpush posts', () => { + let author + let admin + beforeEach(async () => { + author = await Factory.build('user', { slug: 'the-author' }) + await Factory.build( + 'post', + { + id: 'pFirst', + }, + { + author, + categoryIds, + }, + ) + await Factory.build( + 'post', + { + id: 'pSecond', + }, + { + author, + categoryIds, + }, + ) + await Factory.build( + 'post', + { + id: 'pThird', + }, + { + author, + categoryIds, + }, + ) + admin = await Factory.build('user', { + id: 'admin', + role: 'admin', + }) + authenticatedUser = await admin.toJson() + await mutate({ mutation: pushPost, variables: { id: 'pSecond' } }) + authenticatedUser = null + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + authenticatedUser = null + await expect( + mutate({ mutation: unpushPost, variables: { id: 'pSecond' } }), + ).resolves.toMatchObject({ + errors: [{ message: 'Not Authorized!' }], + data: null, + }) + }) + }) + + describe('ordinary users', () => { + it('throws authorization error', async () => { + authenticatedUser = await user.toJson() + await expect( + mutate({ mutation: unpushPost, variables: { id: 'pSecond' } }), + ).resolves.toMatchObject({ + errors: [{ message: 'Not Authorized!' }], + data: null, + }) + }) + }) + + describe('moderators', () => { + let moderator + beforeEach(async () => { + moderator = await user.update({ role: 'moderator', updatedAt: new Date().toISOString() }) + authenticatedUser = await moderator.toJson() + }) + + it('throws authorization error', async () => { + await expect( + mutate({ mutation: unpushPost, variables: { id: 'pSecond' } }), + ).resolves.toMatchObject({ + errors: [{ message: 'Not Authorized!' }], + data: null, + }) + }) + }) + + describe('admins', () => { + it('cancels the push of the post and puts it in the original order', async () => { + authenticatedUser = await admin.toJson() + await expect( + query({ query: Post, variables: { orderBy: ['sortDate_desc'] } }), + ).resolves.toMatchObject({ + errors: undefined, + data: { + Post: [ + { + id: 'pSecond', + }, + { + id: 'pThird', + }, + { + id: 'pFirst', + }, + ], + }, + }) + await expect( + mutate({ mutation: unpushPost, variables: { id: 'pSecond' } }), + ).resolves.toMatchObject({ + errors: undefined, + data: { + unpushPost: { + id: 'pSecond', + }, + }, + }) + await expect( + query({ query: Post, variables: { orderBy: ['sortDate_desc'] } }), + ).resolves.toMatchObject({ + errors: undefined, + data: { + Post: [ + { + id: 'pThird', + }, + { + id: 'pSecond', + }, + { + id: 'pFirst', + }, + ], + }, + }) + }) + }) +}) + describe('pin posts', () => { let author const pinPostMutation = gql` @@ -1097,17 +1375,6 @@ describe('pin posts', () => { authenticatedUser = await admin.toJson() }) - 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 @@ -1451,7 +1718,7 @@ describe('pin posts', () => { }) it('pinned post appear first even when created before other posts', async () => { - await expect(query({ query: postOrderingQuery, variables })).resolves.toMatchObject({ + await expect(query({ query: Post, variables })).resolves.toMatchObject({ data: { Post: [ { @@ -1658,45 +1925,43 @@ describe('pin posts', () => { }) 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, + await expect(query({ query: Post, variables })).resolves.toMatchObject({ + data: { + Post: [ + { + id: 'first-post', + pinned: true, + pinnedAt: expect.any(String), + createdAt: '2019-10-22T17:26:29.070Z', + }, + { + id: 'second-post', + pinned: true, + pinnedAt: expect.any(String), + createdAt: '2018-10-22T17:26:29.070Z', + }, + { + id: 'third-post', + pinned: true, + pinnedAt: expect.any(String), + createdAt: '2017-10-22T17:26:29.070Z', + }, + { + id: 'another-post', + pinned: null, + pinnedAt: null, + createdAt: expect.any(String), + }, + { + id: 'p9876', + pinned: null, + pinnedAt: null, + createdAt: expect.any(String), + }, + ], }, - ) + errors: undefined, + }) }) }) }) diff --git a/backend/src/graphql/resolvers/posts.ts b/backend/src/graphql/resolvers/posts.ts index 34d190ba5..cef255634 100644 --- a/backend/src/graphql/resolvers/posts.ts +++ b/backend/src/graphql/resolvers/posts.ts @@ -158,6 +158,7 @@ export default { SET post += $params SET post.createdAt = toString(datetime()) SET post.updatedAt = toString(datetime()) + SET post.sortDate = toString(datetime()) SET post.clickedCount = 0 SET post.viewedTeaserCount = 0 SET post:${params.postType} @@ -493,6 +494,40 @@ export default { session.close() } }, + pushPost: async (_parent, params, context: Context, _resolveInfo) => { + const posts = ( + await context.database.write({ + query: ` + MATCH (post:Post {id: $id}) + SET post.sortDate = toString(datetime()) + RETURN post {.*}`, + variables: params, + }) + ).records.map((record) => record.get('post')) + + if (posts.length !== 1) { + throw new Error('Could not find Post') + } + + return posts[0] + }, + unpushPost: async (_parent, params, context: Context, _resolveInfo) => { + const posts = ( + await context.database.write({ + query: ` + MATCH (post:Post {id: $id}) + SET post.sortDate = post.createdAt + RETURN post {.*}`, + variables: params, + }) + ).records.map((record) => record.get('post')) + + if (posts.length !== 1) { + throw new Error('Could not find Post') + } + + return posts[0] + }, }, Post: { ...Resolver('Post', { diff --git a/backend/src/graphql/types/type/Post.gql b/backend/src/graphql/types/type/Post.gql index e6b3a00a0..0c654b86b 100644 --- a/backend/src/graphql/types/type/Post.gql +++ b/backend/src/graphql/types/type/Post.gql @@ -103,6 +103,8 @@ enum _PostOrdering { createdAt_desc updatedAt_asc updatedAt_desc + sortDate_asc + sortDate_desc language_asc language_desc pinned_asc @@ -128,6 +130,7 @@ type Post { pinned: Boolean createdAt: String updatedAt: String + sortDate: String language: String pinnedAt: String @cypher( statement: "MATCH (this)<-[pinned:PINNED]-(:User) WHERE NOT this.deleted = true AND NOT this.disabled = true RETURN pinned.createdAt" @@ -241,6 +244,8 @@ type Mutation { pinPost(id: ID!): Post unpinPost(id: ID!): Post markTeaserAsViewed(id: ID!): Post + pushPost(id: ID!): Post! + unpushPost(id: ID!): Post! # Shout the given Type and ID shout(id: ID!, type: ShoutTypeEnum!): Boolean! diff --git a/backend/src/middleware/orderByMiddleware.ts b/backend/src/middleware/orderByMiddleware.ts index 9b437a5e9..c2d2ce447 100644 --- a/backend/src/middleware/orderByMiddleware.ts +++ b/backend/src/middleware/orderByMiddleware.ts @@ -9,7 +9,7 @@ const defaultOrderBy = (resolve, root, args, context, resolveInfo) => { const newestFirst = { kind: 'Argument', name: { kind: 'Name', value: 'orderBy' }, - value: { kind: 'EnumValue', value: 'createdAt_desc' }, + value: { kind: 'EnumValue', value: 'sortDate_desc' }, } const [fieldNode] = copy.fieldNodes if (fieldNode) fieldNode.arguments.push(newestFirst) diff --git a/backend/src/middleware/permissionsMiddleware.ts b/backend/src/middleware/permissionsMiddleware.ts index d4f50bb31..135c7553c 100644 --- a/backend/src/middleware/permissionsMiddleware.ts +++ b/backend/src/middleware/permissionsMiddleware.ts @@ -484,6 +484,8 @@ export default shield( VerifyEmailAddress: isAuthenticated, pinPost: isAdmin, unpinPost: isAdmin, + pushPost: isAdmin, + unpushPost: isAdmin, UpdateDonations: isAdmin, // InviteCode diff --git a/webapp/components/ContentMenu/ContentMenu.spec.js b/webapp/components/ContentMenu/ContentMenu.spec.js index 71e74ac74..ab45240f4 100644 --- a/webapp/components/ContentMenu/ContentMenu.spec.js +++ b/webapp/components/ContentMenu/ContentMenu.spec.js @@ -99,6 +99,82 @@ describe('ContentMenu.vue', () => { }) describe('admin can', () => { + it('push post', async () => { + getters['auth/isAdmin'] = () => true + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + sortDate: 'some-date', + createdAt: 'some-date', + }, + }) + expect( + wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.push'), + ).toHaveLength(1) + wrapper + .findAll('.ds-menu-item') + .filter((item) => item.text() === 'post.menu.push') + .at(0) + .trigger('click') + expect(wrapper.emitted('pushPost')).toEqual([ + [ + { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + sortDate: 'some-date', + createdAt: 'some-date', + }, + ], + ]) + }) + + it('not unpush post which was not pushed', async () => { + getters['auth/isAdmin'] = () => true + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + sortDate: 'some-date', + createdAt: 'some-date', + }, + }) + expect( + wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.unpush'), + ).toHaveLength(0) + }) + + it('unpush post which was pushed', async () => { + getters['auth/isAdmin'] = () => true + const wrapper = await openContentMenu({ + isOwner: false, + resourceType: 'contribution', + resource: { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + sortDate: 'some-date', + createdAt: 'some-other-date', + }, + }) + expect( + wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.unpush'), + ).toHaveLength(1) + wrapper + .findAll('.ds-menu-item') + .filter((item) => item.text() === 'post.menu.unpush') + .at(0) + .trigger('click') + expect(wrapper.emitted('unpushPost')).toEqual([ + [ + { + id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8', + sortDate: 'some-date', + createdAt: 'some-other-date', + }, + ], + ]) + }) + describe('when maxPinnedPosts = 0', () => { beforeEach(() => { maxPinnedPostsMock.mockReturnValue(0) diff --git a/webapp/components/ContentMenu/ContentMenu.vue b/webapp/components/ContentMenu/ContentMenu.vue index 9c2bdc369..dc765c4a3 100644 --- a/webapp/components/ContentMenu/ContentMenu.vue +++ b/webapp/components/ContentMenu/ContentMenu.vue @@ -104,6 +104,26 @@ export default { } } + if (this.isAdmin) { + routes.push({ + label: this.$t(`post.menu.push`), + callback: () => { + this.$emit('pushPost', this.resource) + }, + icon: 'link', + }) + } + + if (this.isAdmin && this.resource.sortDate !== this.resource.createdAt) { + routes.push({ + label: this.$t(`post.menu.unpush`), + callback: () => { + this.$emit('unpushPost', this.resource) + }, + icon: 'link', + }) + } + if (this.resource.isObservedByMe) { routes.push({ label: this.$t(`post.menu.unobserve`), diff --git a/webapp/components/FilterMenu/FilterMenu.spec.js b/webapp/components/FilterMenu/FilterMenu.spec.js index 6769451a0..9f9b29962 100644 --- a/webapp/components/FilterMenu/FilterMenu.spec.js +++ b/webapp/components/FilterMenu/FilterMenu.spec.js @@ -13,7 +13,7 @@ describe('FilterMenu.vue', () => { const getters = { 'posts/isActive': () => false, 'posts/filteredPostTypes': () => [], - 'posts/orderBy': () => 'createdAt_desc', + 'posts/orderBy': () => 'sortDate_desc', 'categories/categoriesActive': () => false, } const actions = { diff --git a/webapp/components/FilterMenu/OrderByFilter.spec.js b/webapp/components/FilterMenu/OrderByFilter.spec.js index b9eaed8ac..c8e711f0f 100644 --- a/webapp/components/FilterMenu/OrderByFilter.spec.js +++ b/webapp/components/FilterMenu/OrderByFilter.spec.js @@ -13,7 +13,7 @@ describe('OrderByFilter', () => { const getters = { 'posts/filteredPostTypes': () => [], 'posts/orderedByCreationDate': () => true, - 'posts/orderBy': () => 'createdAt_desc', + 'posts/orderBy': () => 'sortDate_desc', } const actions = { 'categories/init': jest.fn(), @@ -54,7 +54,7 @@ describe('OrderByFilter', () => { describe('if ordered by oldest', () => { beforeEach(() => { - getters['posts/orderBy'] = jest.fn(() => 'createdAt_asc') + getters['posts/orderBy'] = jest.fn(() => 'sortDate_asc') wrapper = Wrapper() }) @@ -76,20 +76,20 @@ describe('OrderByFilter', () => { }) describe('click "newest-button"', () => { - it('calls TOGGLE_ORDER with "createdAt_desc"', () => { + it('calls TOGGLE_ORDER with "sortDate_desc"', () => { wrapper .find('.order-by-filter .filter-list .base-button[data-test="newest-button"]') .trigger('click') - expect(mutations['posts/TOGGLE_ORDER']).toHaveBeenCalledWith({}, 'createdAt_desc') + expect(mutations['posts/TOGGLE_ORDER']).toHaveBeenCalledWith({}, 'sortDate_desc') }) }) describe('click "oldest-button"', () => { - it('calls TOGGLE_ORDER with "createdAt_asc"', () => { + it('calls TOGGLE_ORDER with "sortDate_asc"', () => { wrapper .find('.order-by-filter .filter-list .base-button[data-test="oldest-button"]') .trigger('click') - expect(mutations['posts/TOGGLE_ORDER']).toHaveBeenCalledWith({}, 'createdAt_asc') + expect(mutations['posts/TOGGLE_ORDER']).toHaveBeenCalledWith({}, 'sortDate_asc') }) }) }) diff --git a/webapp/components/FilterMenu/OrderByFilter.vue b/webapp/components/FilterMenu/OrderByFilter.vue index 87cdcf897..049e1f16f 100644 --- a/webapp/components/FilterMenu/OrderByFilter.vue +++ b/webapp/components/FilterMenu/OrderByFilter.vue @@ -49,10 +49,10 @@ export default { return !this.filteredPostTypes.includes('Event') }, orderedAsc() { - return this.orderedByCreationDate ? 'createdAt_asc' : 'eventStart_desc' + return this.orderedByCreationDate ? 'sortDate_asc' : 'eventStart_desc' }, orderedDesc() { - return this.orderedByCreationDate ? 'createdAt_desc' : 'eventStart_asc' + return this.orderedByCreationDate ? 'sortDate_desc' : 'eventStart_asc' }, sectionTitle() { return this.orderedByCreationDate diff --git a/webapp/components/PostTeaser/PostTeaser.vue b/webapp/components/PostTeaser/PostTeaser.vue index 3b880d4d8..a84c8475c 100644 --- a/webapp/components/PostTeaser/PostTeaser.vue +++ b/webapp/components/PostTeaser/PostTeaser.vue @@ -109,6 +109,8 @@ :is-owner="isAuthor" @pinPost="pinPost" @unpinPost="unpinPost" + @pushPost="pushPost" + @unpushPost="unpushPost" @toggleObservePost="toggleObservePost" /> @@ -222,6 +224,12 @@ export default { unpinPost(post) { this.$emit('unpinPost', post) }, + pushPost(post) { + this.$emit('pushPost', post) + }, + unpushPost(post) { + this.$emit('unpushPost', post) + }, toggleObservePost(postId, value) { this.$emit('toggleObservePost', postId, value) }, diff --git a/webapp/components/_new/features/SearchResults/SearchResults.vue b/webapp/components/_new/features/SearchResults/SearchResults.vue index 94d569e70..71f94a489 100644 --- a/webapp/components/_new/features/SearchResults/SearchResults.vue +++ b/webapp/components/_new/features/SearchResults/SearchResults.vue @@ -48,6 +48,8 @@ @removePostFromList="posts = removePostFromList(post, posts)" @pinPost="pinPost(post, refetchPostList)" @unpinPost="unpinPost(post, refetchPostList)" + @pushPost="pushPost(post, refetchPostList)" + @unpushPost="unpushPost(post, refetchPostList)" @toggleObservePost=" (postId, value) => toggleObservePost(postId, value, refetchPostList) " diff --git a/webapp/graphql/Fragments.js b/webapp/graphql/Fragments.js index 58cdbd30d..4f82eea23 100644 --- a/webapp/graphql/Fragments.js +++ b/webapp/graphql/Fragments.js @@ -73,6 +73,7 @@ export const postFragment = gql` contentExcerpt createdAt updatedAt + sortDate disabled deleted slug diff --git a/webapp/graphql/PostMutations.js b/webapp/graphql/PostMutations.js index 5f29534a3..862615e09 100644 --- a/webapp/graphql/PostMutations.js +++ b/webapp/graphql/PostMutations.js @@ -168,6 +168,40 @@ export default () => { } } `, + pushPost: gql` + mutation ($id: ID!) { + pushPost(id: $id) { + id + title + slug + content + contentExcerpt + language + pinnedBy { + id + name + role + } + } + } + `, + unpushPost: gql` + mutation ($id: ID!) { + unpushPost(id: $id) { + id + title + slug + content + contentExcerpt + language + pinnedBy { + id + name + role + } + } + } + `, markTeaserAsViewed: gql` mutation ($id: ID!) { markTeaserAsViewed(id: $id) { diff --git a/webapp/locales/de.json b/webapp/locales/de.json index 304d2f5be..0c987f5ae 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -856,10 +856,14 @@ "observedSuccessfully": "Du beobachtest diesen Beitrag!", "pin": "Beitrag anheften", "pinnedSuccessfully": "Beitrag erfolgreich angeheftet!", + "push": "Beitrag hochschieben", + "pushedSuccessfully": "Beitrag erfolgreich nach oben geschoben!", "unobserve": "Beitrag nicht mehr beobachten", "unobservedSuccessfully": "Du beobachtest diesen Beitrag nicht mehr!", "unpin": "Beitrag loslösen", - "unpinnedSuccessfully": "Angehefteten Beitrag erfolgreich losgelöst!" + "unpinnedSuccessfully": "Angehefteten Beitrag erfolgreich losgelöst!", + "unpush": "Beitrag hochschieben aufheben", + "unpushedSuccessfully": "Hochschieben des Beitrags erfolgreich rückgängig gemacht!" }, "name": "Beitrag", "pinned": "Meldung", diff --git a/webapp/locales/en.json b/webapp/locales/en.json index 229270d86..6f83754db 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -856,10 +856,14 @@ "observedSuccessfully": "You are now observing this post!", "pin": "Pin post", "pinnedSuccessfully": "Post pinned successfully!", + "push": "Push to top", + "pushedSuccessfully": "Post pushed to top successfully!", "unobserve": "Stop to observe post", "unobservedSuccessfully": "You are no longer observing this post!", "unpin": "Unpin post", - "unpinnedSuccessfully": "Post unpinned successfully!" + "unpinnedSuccessfully": "Post unpinned successfully!", + "unpush": "Cancel push", + "unpushedSuccessfully": "Post push has been canceled!" }, "name": "Article", "pinned": "Announcement", diff --git a/webapp/locales/es.json b/webapp/locales/es.json index b0b369740..71b45e56c 100644 --- a/webapp/locales/es.json +++ b/webapp/locales/es.json @@ -856,10 +856,14 @@ "observedSuccessfully": null, "pin": "Anclar contribución", "pinnedSuccessfully": "¡Contribución anclado con éxito!", + "push": null, + "pushedSuccessfully": null, "unobserve": "Dejar de observar contribución", "unobservedSuccessfully": null, "unpin": "Desanclar contribución", - "unpinnedSuccessfully": "¡Contribución desanclado con éxito!" + "unpinnedSuccessfully": "¡Contribución desanclado con éxito!", + "unpush": null, + "unpushedSuccessfully": null }, "name": "Contribución", "pinned": "Anuncio", diff --git a/webapp/locales/fr.json b/webapp/locales/fr.json index 056c33328..4f4b6251d 100644 --- a/webapp/locales/fr.json +++ b/webapp/locales/fr.json @@ -856,10 +856,14 @@ "observedSuccessfully": null, "pin": "Épingler le Post", "pinnedSuccessfully": "Poste épinglé avec succès!", + "push": null, + "pushedSuccessfully": null, "unobserve": "Ne plus observer le Post", "unobservedSuccessfully": null, "unpin": "Retirer l'épingle du poste", - "unpinnedSuccessfully": "Épingle retirer du Post avec succès!" + "unpinnedSuccessfully": "Épingle retirer du Post avec succès!", + "unpush": null, + "unpushedSuccessfully": null }, "name": "Post", "pinned": "Annonce", diff --git a/webapp/locales/it.json b/webapp/locales/it.json index 7b702bb2a..1cc3e2cea 100644 --- a/webapp/locales/it.json +++ b/webapp/locales/it.json @@ -856,10 +856,14 @@ "observedSuccessfully": null, "pin": null, "pinnedSuccessfully": null, + "push": null, + "pushedSuccessfully": null, "unobserve": null, "unobservedSuccessfully": null, "unpin": null, - "unpinnedSuccessfully": null + "unpinnedSuccessfully": null, + "unpush": null, + "unpushedSuccessfully": null }, "name": "Messaggio", "pinned": null, diff --git a/webapp/locales/nl.json b/webapp/locales/nl.json index defa5c77b..bf2f22c91 100644 --- a/webapp/locales/nl.json +++ b/webapp/locales/nl.json @@ -856,10 +856,14 @@ "observedSuccessfully": null, "pin": null, "pinnedSuccessfully": null, + "push": null, + "pushedSuccessfully": null, "unobserve": null, "unobservedSuccessfully": null, "unpin": null, - "unpinnedSuccessfully": null + "unpinnedSuccessfully": null, + "unpush": null, + "unpushedSuccessfully": null }, "name": "Post", "pinned": null, diff --git a/webapp/locales/pl.json b/webapp/locales/pl.json index 9f6ccd483..b68d28130 100644 --- a/webapp/locales/pl.json +++ b/webapp/locales/pl.json @@ -856,10 +856,14 @@ "observedSuccessfully": null, "pin": null, "pinnedSuccessfully": null, + "push": null, + "pushedSuccessfully": null, "unobserve": null, "unobservedSuccessfully": null, "unpin": null, - "unpinnedSuccessfully": null + "unpinnedSuccessfully": null, + "unpush": null, + "unpushedSuccessfully": null }, "name": "Poczta", "pinned": null, diff --git a/webapp/locales/pt.json b/webapp/locales/pt.json index 0d0fd002a..81e9dc4b2 100644 --- a/webapp/locales/pt.json +++ b/webapp/locales/pt.json @@ -856,10 +856,14 @@ "observedSuccessfully": null, "pin": "Fixar publicação", "pinnedSuccessfully": "Publicação fixada com sucesso!", + "push": null, + "pushedSuccessfully": null, "unobserve": "Deixar de observar publicação", "unobservedSuccessfully": null, "unpin": "Desafixar publicação", - "unpinnedSuccessfully": "Publicação desafixada com sucesso!" + "unpinnedSuccessfully": "Publicação desafixada com sucesso!", + "unpush": null, + "unpushedSuccessfully": null }, "name": "Postar", "pinned": "Anúncio", diff --git a/webapp/locales/ru.json b/webapp/locales/ru.json index fa6036248..d81828486 100644 --- a/webapp/locales/ru.json +++ b/webapp/locales/ru.json @@ -856,10 +856,14 @@ "observedSuccessfully": null, "pin": "Закрепить пост", "pinnedSuccessfully": "Пост больше не закреплен!", + "push": null, + "pushedSuccessfully": null, "unobserve": null, "unobservedSuccessfully": null, "unpin": "Открепить пост", - "unpinnedSuccessfully": "Пост успешно не закреплено!" + "unpinnedSuccessfully": "Пост успешно не закреплено!", + "unpush": null, + "unpushedSuccessfully": null }, "name": "Пост", "pinned": "Объявление", diff --git a/webapp/mixins/postListActions.js b/webapp/mixins/postListActions.js index 779dd5416..73ac5562a 100644 --- a/webapp/mixins/postListActions.js +++ b/webapp/mixins/postListActions.js @@ -38,6 +38,34 @@ export default { }) .catch((error) => this.$toast.error(error.message)) }, + pushPost(post, refetchPostList = () => {}) { + this.$apollo + .mutate({ + mutation: PostMutations().pushPost, + variables: { + id: post.id, + }, + }) + .then(() => { + this.$toast.success(this.$t('post.menu.pushedSuccessfully')) + refetchPostList() + }) + .catch((error) => this.$toast.error(error.message)) + }, + unpushPost(post, refetchPostList = () => {}) { + this.$apollo + .mutate({ + mutation: PostMutations().unpushPost, + variables: { + id: post.id, + }, + }) + .then(() => { + this.$toast.success(this.$t('post.menu.unpushedSuccessfully')) + refetchPostList() + }) + .catch((error) => this.$toast.error(error.message)) + }, toggleObservePost(postId, value, refetchPostList = () => {}) { this.$apollo .mutate({ diff --git a/webapp/pages/groups/_id/_slug.vue b/webapp/pages/groups/_id/_slug.vue index e4db080bc..e662a8d8b 100644 --- a/webapp/pages/groups/_id/_slug.vue +++ b/webapp/pages/groups/_id/_slug.vue @@ -267,6 +267,8 @@ @removePostFromList="posts = removePostFromList(post, posts)" @pinPost="pinPost(post, refetchPostList)" @unpinPost="unpinPost(post, refetchPostList)" + @pushPost="pushPost(post, refetchPostList)" + @unpushPost="unpushPost(post, refetchPostList)" @toggleObservePost=" (postId, value) => toggleObservePost(postId, value, refetchPostList) " @@ -493,7 +495,7 @@ export default { offset: this.offset, filter: this.filter, first: this.pageSize, - orderBy: 'createdAt_desc', + orderBy: 'sortDate_desc', }, updateQuery: UpdateQuery(this, { $state, pageKey: 'profilePagePosts' }), }) @@ -602,7 +604,7 @@ export default { filter: this.filter, first: this.pageSize, offset: 0, - orderBy: 'createdAt_desc', + orderBy: 'sortDate_desc', } }, update({ profilePagePosts }) { diff --git a/webapp/pages/index.spec.js b/webapp/pages/index.spec.js index efe741128..3fd38530a 100644 --- a/webapp/pages/index.spec.js +++ b/webapp/pages/index.spec.js @@ -32,7 +32,7 @@ describe('PostIndex', () => { 'posts/articleSetInPostTypeFilter': () => false, 'posts/eventSetInPostTypeFilter': () => false, 'posts/eventsEnded': () => '', - 'posts/orderBy': () => 'createdAt_desc', + 'posts/orderBy': () => 'sortDate_desc', 'auth/user': () => { return { id: 'u23' } }, diff --git a/webapp/pages/index.vue b/webapp/pages/index.vue index 44f8ff577..5484c2a2e 100644 --- a/webapp/pages/index.vue +++ b/webapp/pages/index.vue @@ -116,6 +116,8 @@ @removePostFromList="posts = removePostFromList(post, posts)" @pinPost="pinPost(post, refetchPostList)" @unpinPost="unpinPost(post, refetchPostList)" + @pushPost="pushPost(post, refetchPostList)" + @unpushPost="unpushPost(post, refetchPostList)" @toggleObservePost=" (postId, value) => toggleObservePost(postId, value, refetchPostList) " diff --git a/webapp/pages/post/_id/_slug/index.vue b/webapp/pages/post/_id/_slug/index.vue index df94c327b..fdb34196b 100644 --- a/webapp/pages/post/_id/_slug/index.vue +++ b/webapp/pages/post/_id/_slug/index.vue @@ -49,6 +49,8 @@ :is-owner="isAuthor" @pinPost="pinPost" @unpinPost="unpinPost" + @pushPost="pushPost" + @unpushPost="unpushPost" @toggleObservePost="toggleObservePost" /> diff --git a/webapp/pages/profile/_id/_slug.vue b/webapp/pages/profile/_id/_slug.vue index 38035e217..7e0c5a899 100644 --- a/webapp/pages/profile/_id/_slug.vue +++ b/webapp/pages/profile/_id/_slug.vue @@ -159,6 +159,8 @@ @removePostFromList="posts = removePostFromList(post, posts)" @pinPost="pinPost(post, refetchPostList)" @unpinPost="unpinPost(post, refetchPostList)" + @pushPost="pushPost(post, refetchPostList)" + @unpushPost="unpushPost(post, refetchPostList)" @toggleObservePost=" (postId, value) => toggleObservePost(postId, value, refetchPostList) " @@ -329,7 +331,7 @@ export default { offset: this.offset, filter: this.filter, first: this.pageSize, - orderBy: 'createdAt_desc', + orderBy: 'sortDate_desc', }, updateQuery: UpdateQuery(this, { $state, pageKey: 'profilePagePosts' }), }) @@ -433,7 +435,7 @@ export default { filter: this.filter, first: this.pageSize, offset: 0, - orderBy: 'createdAt_desc', + orderBy: 'sortDate_desc', } }, update({ profilePagePosts }) { diff --git a/webapp/store/posts.js b/webapp/store/posts.js index 51e34f6c5..f55b727bb 100644 --- a/webapp/store/posts.js +++ b/webapp/store/posts.js @@ -12,7 +12,7 @@ export const state = () => { filter: { ...defaultFilter, }, - order: 'createdAt_desc', + order: 'sortDate_desc', } } @@ -89,7 +89,7 @@ export const mutations = { const filter = clone(state.filter) delete filter.eventStart_gte delete filter.postType_in - state.order = 'createdAt_desc' + state.order = 'sortDate_desc' state.filter = filter }, TOGGLE_POST_TYPE(state, postType) { @@ -101,12 +101,12 @@ export const mutations = { state.order = 'eventStart_asc' } else { delete filter.eventStart_gte - state.order = 'createdAt_desc' + state.order = 'sortDate_desc' } } else { delete filter.eventStart_gte delete filter.postType_in - state.order = 'createdAt_desc' + state.order = 'sortDate_desc' } state.filter = filter }, diff --git a/webapp/store/posts.spec.js b/webapp/store/posts.spec.js index b3c73e124..9fc6df42e 100644 --- a/webapp/store/posts.spec.js +++ b/webapp/store/posts.spec.js @@ -107,9 +107,9 @@ describe('getters', () => { describe('orderBy', () => { it('returns value for graphql query', () => { state = { - order: 'createdAt_desc', + order: 'sortDate_desc', } - expect(getters.orderBy(state)).toEqual('createdAt_desc') + expect(getters.orderBy(state)).toEqual('sortDate_desc') }) }) }) @@ -270,7 +270,7 @@ describe('mutations', () => { order: 'eventStart_asc', } expect(testMutation('Article')).toEqual({ postType_in: ['Article'] }) - expect(getters.orderBy(state)).toEqual('createdAt_desc') + expect(getters.orderBy(state)).toEqual('sortDate_desc') }) it('removes post type filter if same post type is present and sets order', () => { @@ -282,7 +282,7 @@ describe('mutations', () => { order: 'eventStart_asc', } expect(testMutation('Event')).toEqual({}) - expect(getters.orderBy(state)).toEqual('createdAt_desc') + expect(getters.orderBy(state)).toEqual('sortDate_desc') }) it('removes post type filter if called with null', () => { @@ -294,7 +294,7 @@ describe('mutations', () => { order: 'eventStart_asc', } expect(testMutation(null)).toEqual({}) - expect(getters.orderBy(state)).toEqual('createdAt_desc') + expect(getters.orderBy(state)).toEqual('sortDate_desc') }) it('does not get in the way of other filters', () => { @@ -325,7 +325,7 @@ describe('mutations', () => { order: 'eventStart_asc', } expect(testMutation()).toEqual({}) - expect(getters.orderBy(state)).toEqual('createdAt_desc') + expect(getters.orderBy(state)).toEqual('sortDate_desc') }) })