diff --git a/backend/src/db/factories.js b/backend/src/db/factories.js index 7a8979b3c..754f84dec 100644 --- a/backend/src/db/factories.js +++ b/backend/src/db/factories.js @@ -27,7 +27,7 @@ export const cleanDatabase = async (options = {}) => { Factory.define('category') .attr('id', uuid) .attr('icon', 'globe') - .attr('name', 'global-peace-nonviolence') + .attr('name', 'Global Peace & Nonviolence') .after((buildObject, options) => { return neode.create('Category', buildObject) }) @@ -113,6 +113,9 @@ Factory.define('post') .attr('slug', ['slug', 'title'], (slug, title) => { return slug || slugify(title, { lower: true }) }) + .attr('language', ['language'], language => { + return language || 'en' + }) .after(async (buildObject, options) => { const [post, author, categories, tags] = await Promise.all([ neode.create('Post', buildObject), diff --git a/backend/src/schema/resolvers/fileUpload/index.js b/backend/src/schema/resolvers/fileUpload/index.js index 960dde7f9..df0145057 100644 --- a/backend/src/schema/resolvers/fileUpload/index.js +++ b/backend/src/schema/resolvers/fileUpload/index.js @@ -1,24 +1,27 @@ import { createWriteStream } from 'fs' import path from 'path' import slug from 'slug' +import uuid from 'uuid/v4' -const storeUpload = ({ createReadStream, fileLocation }) => - new Promise((resolve, reject) => +const localFileUpload = async ({ createReadStream, uniqueFilename }) => { + await new Promise((resolve, reject) => createReadStream() - .pipe(createWriteStream(`public${fileLocation}`)) + .pipe(createWriteStream(`public${uniqueFilename}`)) .on('finish', resolve) .on('error', reject), ) + return uniqueFilename +} -export default async function fileUpload(params, { file, url }, uploadCallback = storeUpload) { +export default async function fileUpload(params, { file, url }, uploadCallback = localFileUpload) { const upload = params[file] if (upload) { const { createReadStream, filename } = await upload - const { name } = path.parse(filename) - const fileLocation = `/uploads/${Date.now()}-${slug(name)}` - await uploadCallback({ createReadStream, fileLocation }) + const { name, ext } = path.parse(filename) + const uniqueFilename = `/uploads/${uuid()}-${slug(name)}${ext}` + const location = await uploadCallback({ createReadStream, uniqueFilename }) delete params[file] - params[url] = fileLocation + params[url] = location } return params diff --git a/backend/src/schema/resolvers/fileUpload/spec.js b/backend/src/schema/resolvers/fileUpload/spec.js index 5767d6457..fee0bf81b 100644 --- a/backend/src/schema/resolvers/fileUpload/spec.js +++ b/backend/src/schema/resolvers/fileUpload/spec.js @@ -1,5 +1,7 @@ import fileUpload from '.' +const uuid = '[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}' + describe('fileUpload', () => { let params let uploadCallback @@ -13,7 +15,7 @@ describe('fileUpload', () => { createReadStream: jest.fn(), }, } - uploadCallback = jest.fn() + uploadCallback = jest.fn(({ uniqueFilename }) => uniqueFilename) }) it('calls uploadCallback', async () => { @@ -24,20 +26,13 @@ describe('fileUpload', () => { describe('file name', () => { it('saves the upload url in params[url]', async () => { await fileUpload(params, { file: 'uploadAttribute', url: 'attribute' }, uploadCallback) - expect(params.attribute).toMatch(/^\/uploads\/\d+-avatar$/) - }) - - it('uses the name without file ending', async () => { - params.uploadAttribute.filename = 'somePng.png' - await fileUpload(params, { file: 'uploadAttribute', url: 'attribute' }, uploadCallback) - expect(params.attribute).toMatch(/^\/uploads\/\d+-somePng/) + expect(params.attribute).toMatch(new RegExp(`^/uploads/${uuid}-avatar.jpg`)) }) it('creates a url safe name', async () => { - params.uploadAttribute.filename = - '/path/to/awkward?/ file-location/?foo- bar-avatar.jpg?foo- bar' + params.uploadAttribute.filename = '/path/to/awkward?/ file-location/?foo- bar-avatar.jpg' await fileUpload(params, { file: 'uploadAttribute', url: 'attribute' }, uploadCallback) - expect(params.attribute).toMatch(/^\/uploads\/\d+-foo-bar-avatar$/) + expect(params.attribute).toMatch(new RegExp(`/uploads/${uuid}-foo-bar-avatar.jpg$`)) }) describe('in case of duplicates', () => { @@ -50,7 +45,6 @@ describe('fileUpload', () => { uploadCallback, ) - await new Promise(resolve => setTimeout(resolve, 1000)) const { attribute: second } = await fileUpload( { ...params, diff --git a/backend/test/features/support/steps.js b/backend/test/features/support/steps.js index e11c69faa..e15801f83 100644 --- a/backend/test/features/support/steps.js +++ b/backend/test/features/support/steps.js @@ -13,7 +13,7 @@ function createUser (slug) { return Factory.build('user', { name: slug, }, { - password: '1234' + password: '1234', email: 'example@test.org', }) // await login({ email: 'example@test.org', password: '1234' }) diff --git a/cypress/fixtures/humanconnection.png b/cypress/fixtures/humanconnection.png new file mode 100644 index 000000000..f0576413f Binary files /dev/null and b/cypress/fixtures/humanconnection.png differ diff --git a/cypress/integration/common/post.js b/cypress/integration/common/post.js index 39407ef4f..f728449d2 100644 --- a/cypress/integration/common/post.js +++ b/cypress/integration/common/post.js @@ -1,8 +1,11 @@ import { When, Then } from "cypress-cucumber-preprocessor/steps"; +import locales from '../../../webapp/locales' +import orderBy from 'lodash/orderBy' +const languages = orderBy(locales, 'name') const narratorAvatar = "https://s3.amazonaws.com/uifaces/faces/twitter/nerrsoft/128.jpg"; - +let expectedValue = { title: 'new post', content: 'new post content', src: 'onourjourney' } When("I type in a comment with {int} characters", size => { var c=""; for (var i = 0; i < size; i++) { @@ -83,3 +86,74 @@ And("the post with title {string} has a ribbon for pinned posts", (title) => { Then("I see a toaster with {string}", (title) => { cy.get(".iziToast-message").should("contain", title); }) + +Then("I should be able to {string} a teaser image", condition => { + cy.reload() + let teaserImageUpload = "onourjourney.png"; + if (condition === 'change') teaserImageUpload = "humanconnection.png"; + cy.fixture(teaserImageUpload).as('postTeaserImage').then(function() { + cy.get("#postdropzone").upload( + { fileContent: this.postTeaserImage, fileName: teaserImageUpload, mimeType: "image/png" }, + { subjectType: "drag-n-drop", force: true } + ); + }) +}) + +Then('confirm crop', () => { + cy.get('.crop-confirm') + .click() +}) + +Then("I add all required fields", () => { + cy.get('input[name="title"]') + .type('new post') + .get(".editor .ProseMirror") + .type('new post content') + .get(".categories-select .base-button") + .first() + .click() + .get('.ds-flex-item > .ds-form-item .ds-select ') + .click() + .get('.ds-select-option') + .eq(languages.findIndex(l => l.code === 'en')) + .click() +}) + +Then("the post was saved successfully with the {string} teaser image", condition => { + if (condition === 'updated') + expectedValue = { title: 'to be updated', content: 'successfully updated', src: 'humanconnection' } + cy.get(".ds-card-content > .ds-heading") + .should("contain", expectedValue.title) + .get(".content") + .should("contain", expectedValue.content) + .get('.post-page img') + .should("have.attr", "src") + .and("contains", expectedValue.src) +}) + +Then("the first image should be removed from the preview", () => { + cy.fixture("humanconnection.png").as('postTeaserImage').then(function() { + cy.get("#postdropzone") + .children() + .get('img.thumbnail-preview') + .should('have.length', 1) + .and('have.attr', 'src') + .and('contain', this.postTeaserImage) + }) +}) + +Then('the post was saved successfully without a teaser image', () => { + cy.get(".ds-card-content > .ds-heading") + .should("contain", 'new post') + .get(".content") + .should("contain", 'new post content') + .get('.post-page') + .should('exist') + .get('.post-page img.ds-card-image') + .should('not.exist') +}) + +Then('I should be able to remove it', () => { + cy.get('.crop-cancel') + .click() +}) \ No newline at end of file diff --git a/cypress/integration/common/steps.js b/cypress/integration/common/steps.js index a4bdb6475..3d46e74f1 100644 --- a/cypress/integration/common/steps.js +++ b/cypress/integration/common/steps.js @@ -48,6 +48,7 @@ Given("I am logged in", () => { }); Given("I log in as {string}", name => { + cy.logout() cy.neode() .first("User", { name @@ -237,25 +238,17 @@ Given("we have the following comments in our database:", table => { }); Given("we have the following posts in our database:", table => { - cy.factory().build('category', { - id: `cat-456`, - name: "Just For Fun", - slug: `just-for-fun`, - icon: "smile" - }) - - table.hashes().forEach((attributesOrOptions, i) => { - cy.factory().build("post", { - ...attributesOrOptions, - deleted: Boolean(attributesOrOptions.deleted), - disabled: Boolean(attributesOrOptions.disabled), - pinned: Boolean(attributesOrOptions.pinned), - }, { - ...attributesOrOptions, - categoryIds: ['cat-456'] - }); - }) -}); + table.hashes().forEach((attributesOrOptions, i) => { + cy.factory().build("post", { + ...attributesOrOptions, + deleted: Boolean(attributesOrOptions.deleted), + disabled: Boolean(attributesOrOptions.disabled), + pinned: Boolean(attributesOrOptions.pinned), + }, { + ...attributesOrOptions, + }); + }) +}) Then("I see a success message:", message => { cy.contains(message); @@ -269,6 +262,7 @@ When( "I click on the big plus icon in the bottom right corner to create post", () => { cy.get(".post-add-button").click(); + cy.location("pathname").should('eq', '/post/create') } ); diff --git a/cypress/integration/post/ImageUploader.feature b/cypress/integration/post/ImageUploader.feature new file mode 100644 index 000000000..061001ffb --- /dev/null +++ b/cypress/integration/post/ImageUploader.feature @@ -0,0 +1,47 @@ +Feature: Upload Teaser Image + As a user + I would like to be able to add a teaser image to my Post + So that I can personalize my posts + + + Background: + Given I have a user account + Given I am logged in + Given we have the following posts in our database: + | authorId | id | title | content | + | id-of-peter-pan | p1 | Post to be updated | successfully updated | + + Scenario: Create a Post with a Teaser Image + When I click on the big plus icon in the bottom right corner to create post + Then I should be able to "add" a teaser image + And confirm crop + And I add all required fields + And I click on "Save" + Then I get redirected to ".../new-post" + And the post was saved successfully with the "new" teaser image + + Scenario: Update a Post to add an image + Given I am on the 'post/edit/p1' page + And I should be able to "change" a teaser image + And confirm crop + And I click on "Save" + Then I see a toaster with "Saved!" + And I get redirected to ".../post-to-be-updated" + Then the post was saved successfully with the "updated" teaser image + + Scenario: Add image, then add a different image + When I click on the big plus icon in the bottom right corner to create post + Then I should be able to "add" a teaser image + And confirm crop + And I should be able to "change" a teaser image + And confirm crop + And the first image should be removed from the preview + + Scenario: Add image, then delete it + When I click on the big plus icon in the bottom right corner to create post + Then I should be able to "add" a teaser image + And I should be able to remove it + And I add all required fields + And I click on "Save" + Then I get redirected to ".../new-post" + And the post was saved successfully without a teaser image \ No newline at end of file diff --git a/webapp/components/CategoriesSelect/CategoriesSelect.vue b/webapp/components/CategoriesSelect/CategoriesSelect.vue index 3e240e435..54c0d3524 100644 --- a/webapp/components/CategoriesSelect/CategoriesSelect.vue +++ b/webapp/components/CategoriesSelect/CategoriesSelect.vue @@ -1,5 +1,5 @@