diff --git a/backend/src/models/Post.js b/backend/src/models/Post.js index fd1e5b2ac..969b67631 100644 --- a/backend/src/models/Post.js +++ b/backend/src/models/Post.js @@ -41,4 +41,6 @@ export default { language: { type: 'string', allow: [null] }, imageBlurred: { type: 'boolean', default: false }, imageAspectRatio: { type: 'float', default: 1.0 }, + pinned: { type: 'boolean', default: null, valid: [null, true] }, + pinnedAt: { type: 'string', isoDate: true }, } diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index dcbd16d5d..0af6d0704 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -682,58 +682,62 @@ describe('UpdatePost', () => { }) describe('PostOrdering', () => { - let pinnedPost, admin beforeEach(async () => { - ;[pinnedPost] = await Promise.all([ - neode.create('Post', { - id: 'im-a-pinned-post', - pinned: true, - }), - neode.create('Post', { - id: 'i-was-created-after-pinned-post', - createdAt: '2019-10-22T17:26:29.070Z', // this should always be 3rd - }), - ]) - admin = await user.update({ - role: 'admin', - name: 'Admin', - updatedAt: new Date().toISOString(), + await factory.create('Post', { + id: 'im-a-pinned-post', + createdAt: '2019-11-22T17:26:29.070Z', + pinned: true, + }) + await factory.create('Post', { + id: 'i-was-created-before-pinned-post', + // fairly old, so this should be 3rd + createdAt: '2019-10-22T17:26:29.070Z', }) - await admin.relateTo(pinnedPost, 'pinned') }) - it('pinned post appear first even when created before other posts', async () => { - const postOrderingQuery = gql` - query($orderBy: [_PostOrdering]) { - Post(orderBy: $orderBy) { - id - pinnedAt + 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 () => { + const postOrderingQuery = gql` + query($orderBy: [_PostOrdering]) { + Post(orderBy: $orderBy) { + id + pinned + createdAt + pinnedAt + } } - } - ` - const expected = { - data: { - Post: [ - { - id: 'im-a-pinned-post', - pinnedAt: expect.any(String), - }, - { - id: 'p9876', - pinnedAt: null, - }, - { - id: 'i-was-created-after-pinned-post', - pinnedAt: null, - }, - ], - }, - errors: undefined, - } - variables = { orderBy: ['pinned_desc', 'createdAt_desc'] } - await expect(query({ query: postOrderingQuery, variables })).resolves.toMatchObject( - expected, - ) + ` + 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, + }) + }) }) }) }) diff --git a/backend/src/seed/factories/posts.js b/backend/src/seed/factories/posts.js index 55f4f95fb..3295665b7 100644 --- a/backend/src/seed/factories/posts.js +++ b/backend/src/seed/factories/posts.js @@ -21,11 +21,15 @@ export default function create() { categoryIds: [], imageBlurred: false, imageAspectRatio: 1.333, + pinned: null, } args = { ...defaults, ...args, } + // Convert false to null + args.pinned = args.pinned || null + args.slug = args.slug || slugify(args.title, { lower: true }) args.contentExcerpt = args.contentExcerpt || args.content @@ -50,9 +54,21 @@ export default function create() { if (author && authorId) throw new Error('You provided both author and authorId') if (authorId) author = await neodeInstance.find('User', authorId) author = author || (await factoryInstance.create('User')) - const post = await neodeInstance.create('Post', args) await post.relateTo(author, 'author') + + if (args.pinned) { + args.pinnedAt = args.pinnedAt || new Date().toISOString() + if (!args.pinnedBy) { + const admin = await factoryInstance.create('User', { + role: 'admin', + updatedAt: new Date().toISOString(), + }) + await admin.relateTo(post, 'pinned') + args.pinnedBy = admin + } + } + await Promise.all(categories.map(c => c.relateTo(post, 'post'))) await Promise.all(tags.map(t => t.relateTo(post, 'post'))) return post diff --git a/cypress/features.md b/cypress/features.md index 3adfd8771..60980703d 100644 --- a/cypress/features.md +++ b/cypress/features.md @@ -249,10 +249,12 @@ Shows automatically related actions for existing post. ### Administration +[Cucumber Features](https://github.com/Human-Connection/Human-Connection/tree/master/cypress/integration/administration) + * Provide Admin-Interface to send Users Invite Code * Static Pages for Data Privacy Statement ... * Create, edit and delete Announcements -* Show Announcements on top of User Interface +* Pin a post to inform users ### Invitation diff --git a/cypress/integration/administration/PinPost.feature b/cypress/integration/administration/PinPost.feature new file mode 100644 index 000000000..40ff9cda5 --- /dev/null +++ b/cypress/integration/administration/PinPost.feature @@ -0,0 +1,36 @@ +Feature: Pin a post + As an admin + I want to pin a post so that it always appears at the top + In order to make sure all network users read it - e.g. notify people about security incidents, maintenance downtimes + + + Background: + Given we have the following posts in our database: + | id | title | pinned | createdAt | + | p1 | Some other post | | 2020-01-21 | + | p2 | Houston we have a problem | x | 2020-01-20 | + | p3 | Yet another post | | 2020-01-19 | + + Scenario: Pinned post always appears on the top of the newsfeed + Given I am logged in with a "user" role + Then the first post on the landing page has the title: + """ + Houston we have a problem + """ + And the post with title "Houston we have a problem" has a ribbon for pinned posts + + Scenario: Ordinary users cannot pin a post + Given I am logged in with a "user" role + When I open the content menu of post "Yet another post" + Then there is no button to pin a post + + Scenario: Admins are allowed to pin a post + Given I am logged in with a "admin" role + And I open the content menu of post "Yet another post" + When I click on 'Pin post' + Then I see a toaster with "Post pinned successfully" + And the first post on the landing page has the title: + """ + Yet another post + """ + And the post with title "Yet another post" has a ribbon for pinned posts diff --git a/cypress/integration/common/post.js b/cypress/integration/common/post.js index 4d1856bcc..c4da93c7d 100644 --- a/cypress/integration/common/post.js +++ b/cypress/integration/common/post.js @@ -44,3 +44,31 @@ Then("I should see an abreviated version of my comment", () => { Then("the editor should be cleared", () => { cy.get(".ProseMirror p").should("have.class", "is-empty"); }); + +When("I open the content menu of post {string}", (title)=> { + cy.contains('.post-card', title) + .find('.content-menu .base-button') + .click() +}) + +When("I click on 'Pin post'", (string)=> { + cy.get("a.ds-menu-item-link").contains("Pin post") + .click() +}) + +Then("there is no button to pin a post", () => { + cy.get("a.ds-menu-item-link") + .should('contain', "Report Post") // sanity check + .should('not.contain', "Pin post") +}) + +And("the post with title {string} has a ribbon for pinned posts", (title) => { + cy.get("article.post-card").contains(title) + .parent() + .find("div.ribbon.ribbon--pinned") + .should("contain", "Announcement") +}) + +Then("I see a toaster with {string}", (title) => { + cy.get(".iziToast-message").should("contain", title); +}) diff --git a/cypress/integration/common/report.js b/cypress/integration/common/report.js index 1ba3e2e83..710928ff2 100644 --- a/cypress/integration/common/report.js +++ b/cypress/integration/common/report.js @@ -170,5 +170,4 @@ When("they have a post someone has reported", () => { authorId: 'annnoying-user', title, }); - }) diff --git a/cypress/integration/common/steps.js b/cypress/integration/common/steps.js index 53b5f7c3d..9a5c02d08 100644 --- a/cypress/integration/common/steps.js +++ b/cypress/integration/common/steps.js @@ -212,6 +212,7 @@ Given("we have the following posts in our database:", table => { ...postAttributes, deleted: Boolean(postAttributes.deleted), disabled: Boolean(postAttributes.disabled), + pinned: Boolean(postAttributes.pinned), categoryIds: ['cat-456'] } cy.factory().create("Post", postAttributes); diff --git a/cypress/integration/moderation/ReportContent.feature b/cypress/integration/moderation/ReportContent.feature index c87d41230..105bad5e6 100644 --- a/cypress/integration/moderation/ReportContent.feature +++ b/cypress/integration/moderation/ReportContent.feature @@ -9,10 +9,10 @@ Feature: Report and Moderate Background: Given we have the following user accounts: - | id | name | - | u67 | David Irving | + | id | name | + | u67 | David Irving | | annoying-user | I'm gonna mute Moderators and Admins HA HA HA | - + Given we have the following posts in our database: | authorId | id | title | content | | u67 | p1 | The Truth about the Holocaust | It never existed! | diff --git a/webapp/components/PostCard/PostCard.vue b/webapp/components/PostCard/PostCard.vue index b99474ebc..d5bc39f61 100644 --- a/webapp/components/PostCard/PostCard.vue +++ b/webapp/components/PostCard/PostCard.vue @@ -132,7 +132,7 @@ export default { ) }, isPinned() { - return this.post && this.post.pinnedBy + return this.post && this.post.pinned }, }, methods: { diff --git a/webapp/graphql/Fragments.js b/webapp/graphql/Fragments.js index 1edcd364c..cb7af1624 100644 --- a/webapp/graphql/Fragments.js +++ b/webapp/graphql/Fragments.js @@ -52,6 +52,7 @@ export const postFragment = gql` } pinnedAt imageAspectRatio + pinned } ` diff --git a/webapp/locales/en.json b/webapp/locales/en.json index 79a101b09..1906a5f6f 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -413,8 +413,8 @@ "name": "Take action" }, "menu": { - "edit": "Edit Post", - "delete": "Delete Post", + "edit": "Edit post", + "delete": "Delete post", "pin": "Pin post", "pinnedSuccessfully": "Post pinned successfully!", "unpin": "Unpin post",