diff --git a/backend/src/db/factories.js b/backend/src/db/factories.js index 7a8979b3c..1aa369e95 100644 --- a/backend/src/db/factories.js +++ b/backend/src/db/factories.js @@ -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/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..2c35cb280 100644 --- a/cypress/integration/common/post.js +++ b/cypress/integration/common/post.js @@ -1,5 +1,8 @@ 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"; @@ -83,3 +86,71 @@ 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 => { + 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(".base-button") + .contains("Just for Fun") + .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 => { + cy.get(".ds-card-content > .ds-heading") + .should("contain", condition === 'new' ? 'new post' : 'to be updated') + .get(".content") + .should("contain", condition === 'new' ? 'new post content' : 'successfully updated') + .get('.post-page img') + .should("have.attr", "src") + .and("contains", condition === 'new' ? "onourjourney" : "humanconnection") +}) + +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/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/TeaserImage/TeaserImage.vue b/webapp/components/TeaserImage/TeaserImage.vue index 5943526c7..82ab65318 100644 --- a/webapp/components/TeaserImage/TeaserImage.vue +++ b/webapp/components/TeaserImage/TeaserImage.vue @@ -68,6 +68,7 @@ export default { oldImage: null, error: false, showCropper: false, + imageAspectRatio: null, } }, methods: { @@ -113,29 +114,41 @@ export default { }, cropImage() { this.showCropper = false + if (this.file.type === 'image/png' || this.file.type === 'image/svg+xml') { + this.uploadSvgOrPng() + } else { + this.uploadJpeg() + } + }, + uploadSvgOrPng() { + this.imageAspectRatio = this.file.width / this.file.height || 1.0 + this.image = new Image() + this.image.src = this.file.dataURL + this.setupPreview() + this.emitImageData(this.file) + }, + uploadJpeg() { const canvas = this.cropper.getCroppedCanvas() canvas.toBlob(blob => { - const imageAspectRatio = canvas.width / canvas.height - this.setupPreview(canvas) - this.removeCropper() + this.imageAspectRatio = canvas.width / canvas.height + this.image = new Image() + this.image.src = canvas.toDataURL() + this.setupPreview() const croppedImageFile = new File([blob], this.file.name, { type: this.file.type }) - this.$emit('addTeaserImage', croppedImageFile) - this.$emit('addImageAspectRatio', imageAspectRatio) + this.emitImageData(croppedImageFile) }, 'image/jpeg') }, - setupPreview(canvas) { - this.image = new Image() - this.image.src = canvas.toDataURL() + setupPreview() { this.image.classList.add('thumbnail-preview') this.thumbnailElement.appendChild(this.image) }, cancelCrop() { - this.showCropper = false if (this.oldImage) this.thumbnailElement.appendChild(this.oldImage) - this.removeCropper() + this.showCropper = false }, - removeCropper() { - this.editor.removeChild(document.querySelectorAll('.cropper-container')[0]) + emitImageData(imageFile) { + this.$emit('addTeaserImage', imageFile) + this.$emit('addImageAspectRatio', this.imageAspectRatio) }, }, }