From e8ff4ac1dd36dc7a1dccf1238f8e119258b475ba Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Tue, 23 May 2017 09:18:03 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20add=20tests=20for=20`gh-uploader`?= =?UTF-8?q?=20component=20(#701)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit no issue - filled in tests ready to start work on https://github.com/TryGhost/Ghost/issues/8455 --- ghost/admin/app/components/gh-uploader.js | 56 ++- .../templates/components/gh-progress-bar.hbs | 2 +- .../app/templates/components/gh-uploader.hbs | 16 +- .../components/gh-uploader-test.js | 355 +++++++++++++++++- 4 files changed, 392 insertions(+), 37 deletions(-) diff --git a/ghost/admin/app/components/gh-uploader.js b/ghost/admin/app/components/gh-uploader.js index 2adeceb731..62cd691a62 100644 --- a/ghost/admin/app/components/gh-uploader.js +++ b/ghost/admin/app/components/gh-uploader.js @@ -5,6 +5,7 @@ import {isEmpty} from 'ember-utils'; import {task, all} from 'ember-concurrency'; import ghostPaths from 'ghost-admin/utils/ghost-paths'; import EmberObject from 'ember-object'; +import run from 'ember-runloop'; // TODO: this is designed to be a more re-usable/composable upload component, it // should be able to replace the duplicated upload logic in: @@ -36,11 +37,10 @@ export default Component.extend({ tagName: '', ajax: injectService(), - notifications: injectService(), // Public attributes accept: '', - extensions: null, + extensions: '', files: null, paramName: 'uploadimage', // TODO: is this the best default? uploadUrl: null, @@ -55,7 +55,6 @@ export default Component.extend({ // Private _defaultUploadUrl: '/uploads/', _files: null, - _isUploading: false, _uploadTrackers: null, // Closure actions @@ -87,8 +86,9 @@ export default Component.extend({ // if we have new files, validate and start an upload let files = this.get('files'); if (files && files !== this._files) { - if (this._isUploading) { - throw new Error('Adding new files whilst an upload is in progress is not supported.'); + if (this.get('_uploadFiles.isRunning')) { + // eslint-disable-next-line + console.error('Adding new files whilst an upload is in progress is not supported.'); } this._files = files; @@ -133,6 +133,11 @@ export default Component.extend({ let extensions = this.get('extensions'); let [, extension] = (/(?:\.([^.]+))?$/).exec(file.name); + // if extensions is falsy exit early and accept all files + if (!extensions) { + return true; + } + if (!isEmberArray(extensions)) { extensions = extensions.split(','); } @@ -159,6 +164,10 @@ export default Component.extend({ // populates this.errors and this.uploadUrls yield all(uploads); + if (!isEmpty(this.get('errors'))) { + this.onFailed(this.get('errors')); + } + this.onComplete(this.get('uploadUrls')); }).drop(), @@ -180,32 +189,50 @@ export default Component.extend({ let xhr = new window.XMLHttpRequest(); xhr.upload.addEventListener('progress', (event) => { - tracker.update(event); - this._updateProgress(); + run(() => { + tracker.update(event); + this._updateProgress(); + }); }, false); return xhr; } }); + // force tracker progress to 100% in case we didn't get a final event, + // eg. when using mirage + tracker.update({loaded: file.size, total: file.size}); + this._updateProgress(); + // TODO: is it safe to assume we'll only get a url back? let uploadUrl = JSON.parse(response); - - this.get('uploadUrls').push({ + let result = { fileName: file.name, url: uploadUrl - }); + }; + + this.get('uploadUrls').pushObject(result); + this.onUploadSuccess(result); return true; } catch (error) { - console.log('error', error); // eslint-disable-line + // grab custom error message if present + let message = error.errors && error.errors[0].message; - // TODO: check for or expose known error types? - this.get('errors').push({ + // fall back to EmberData/ember-ajax default message for error type + if (!message) { + message = error.message; + } + + let result = { fileName: file.name, message: error.errors[0].message - }); + }; + + // TODO: check for or expose known error types? + this.get('errors').pushObject(result); + this.onUploadFail(result); } }), @@ -247,7 +274,6 @@ export default Component.extend({ this.set('uploadedSize', 0); this.set('uploadPercentage', 0); this._uploadTrackers = []; - this._isUploading = false; }, actions: { diff --git a/ghost/admin/app/templates/components/gh-progress-bar.hbs b/ghost/admin/app/templates/components/gh-progress-bar.hbs index c3731e5ce5..8c60be7d3f 100644 --- a/ghost/admin/app/templates/components/gh-progress-bar.hbs +++ b/ghost/admin/app/templates/components/gh-progress-bar.hbs @@ -1,5 +1,5 @@
-
+
diff --git a/ghost/admin/app/templates/components/gh-uploader.hbs b/ghost/admin/app/templates/components/gh-uploader.hbs index 57574d79ca..5725cd698e 100644 --- a/ghost/admin/app/templates/components/gh-uploader.hbs +++ b/ghost/admin/app/templates/components/gh-uploader.hbs @@ -1,10 +1,6 @@ -{{#if hasBlock}} - {{yield (hash - progressBar=(component "gh-progress-bar" percentage=uploadPercentage) - files=files - errors=errors - cancel=(action "cancel") - )}} -{{else}} - {{!-- TODO: default uploader interface --}} -{{/if}} +{{yield (hash + progressBar=(component "gh-progress-bar" percentage=uploadPercentage) + files=files + errors=errors + cancel=(action "cancel") +)}} diff --git a/ghost/admin/tests/integration/components/gh-uploader-test.js b/ghost/admin/tests/integration/components/gh-uploader-test.js index 192689106f..8ce78137c8 100644 --- a/ghost/admin/tests/integration/components/gh-uploader-test.js +++ b/ghost/admin/tests/integration/components/gh-uploader-test.js @@ -2,23 +2,356 @@ import {expect} from 'chai'; import {describe, it} from 'mocha'; import {setupComponentTest} from 'ember-mocha'; import hbs from 'htmlbars-inline-precompile'; +import {createFile} from '../../helpers/file-upload'; +import Pretender from 'pretender'; +import sinon from 'sinon'; +import wait from 'ember-test-helpers/wait'; +import run from 'ember-runloop'; +import {click, find, findAll} from 'ember-native-dom-helpers'; +import testSelector from 'ember-test-selectors'; + +const stubSuccessfulUpload = function (server, delay = 0) { + server.post('/ghost/api/v0.1/uploads/', function () { + return [200, {'Content-Type': 'application/json'}, '"/content/images/test.png"']; + }, delay); +}; + +const stubFailedUpload = function (server, code, error, delay = 0) { + server.post('/ghost/api/v0.1/uploads/', function () { + return [code, {'Content-Type': 'application/json'}, JSON.stringify({ + errors: [{ + errorType: error, + message: `Error: ${error}` + }] + })]; + }, delay); +}; describe('Integration: Component: gh-uploader', function() { setupComponentTest('gh-uploader', { integration: true }); - it('renders', function() { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.on('myAction', function(val) { ... }); - // Template block usage: - // this.render(hbs` - // {{#gh-uploader}} - // template content - // {{/gh-uploader}} - // `); + let server; - this.render(hbs`{{gh-uploader}}`); - expect(this.$()).to.have.length(1); + beforeEach(function () { + server = new Pretender(); + }); + + afterEach(function () { + server.shutdown(); + }); + + describe('uploads', function () { + beforeEach(function () { + stubSuccessfulUpload(server); + }); + + it('triggers uploads when `files` is set', async function () { + this.render(hbs`{{#gh-uploader files=files}}{{/gh-uploader}}`); + + this.set('files', [createFile()]); + await wait(); + + let [lastRequest] = server.handledRequests; + expect(server.handledRequests.length).to.equal(1); + expect(lastRequest.url).to.equal('/ghost/api/v0.1/uploads/'); + // requestBody is a FormData object + // this will fail in anything other than Chrome and Firefox + // https://developer.mozilla.org/en-US/docs/Web/API/FormData#Browser_compatibility + expect(lastRequest.requestBody.has('uploadimage')).to.be.true; + }); + + it('triggers multiple uploads', async function () { + this.render(hbs`{{#gh-uploader files=files}}{{/gh-uploader}}`); + + this.set('files', [createFile(), createFile()]); + await wait(); + + expect(server.handledRequests.length).to.equal(2); + }); + + it('triggers onStart when upload starts', async function () { + this.set('uploadStarted', sinon.spy()); + + this.render(hbs`{{#gh-uploader files=files onStart=(action uploadStarted)}}{{/gh-uploader}}`); + this.set('files', [createFile(), createFile()]); + await wait(); + + expect(this.get('uploadStarted').calledOnce).to.be.true; + }); + + it('triggers onUploadSuccess when a file uploads', async function () { + this.set('fileUploaded', sinon.spy()); + + this.render(hbs`{{#gh-uploader files=files onUploadSuccess=(action fileUploaded)}}{{/gh-uploader}}`); + this.set('files', [createFile(['test'], {name: 'file1.png'}), createFile()]); + await wait(); + + // triggered for each file + expect(this.get('fileUploaded').calledTwice).to.be.true; + + // filename and url is passed in arg + let firstCall = this.get('fileUploaded').getCall(0); + expect(firstCall.args[0].fileName).to.equal('file1.png'); + expect(firstCall.args[0].url).to.equal('/content/images/test.png'); + }); + + it('triggers onComplete when all files uploaded', async function () { + this.set('uploadsFinished', sinon.spy()); + + this.render(hbs`{{#gh-uploader files=files onComplete=(action uploadsFinished)}}{{/gh-uploader}}`); + this.set('files', [ + createFile(['test'], {name: 'file1.png'}), + createFile(['test'], {name: 'file2.png'}) + ]); + await wait(); + + expect(this.get('uploadsFinished').calledOnce).to.be.true; + + // array of filenames and urls is passed in arg + let [result] = this.get('uploadsFinished').getCall(0).args; + expect(result.length).to.equal(2); + expect(result[0].fileName).to.equal('file1.png'); + expect(result[0].url).to.equal('/content/images/test.png'); + expect(result[1].fileName).to.equal('file2.png'); + expect(result[1].url).to.equal('/content/images/test.png'); + }); + + it('doesn\'t allow new files to be set whilst uploading', async function () { + let errorSpy = sinon.spy(console, 'error'); + stubSuccessfulUpload(server, 100); + + this.render(hbs`{{#gh-uploader files=files}}{{/gh-uploader}}`); + this.set('files', [createFile()]); + + // logs error because upload is in progress + run.later(() => { + this.set('files', [createFile()]); + }, 50); + + // runs ok because original upload has finished + run.later(() => { + this.set('files', [createFile()]); + }, 200); + + await wait(); + + expect(server.handledRequests.length).to.equal(2); + expect(errorSpy.calledOnce).to.be.true; + errorSpy.restore(); + }); + + it('yields progressBar component with total upload progress', async function () { + stubSuccessfulUpload(server, 200); + + this.render(hbs` + {{#gh-uploader files=files as |uploader|}} + {{uploader.progressBar}} + {{/gh-uploader}}`); + + this.set('files', [createFile(), createFile()]); + + run.later(() => { + expect(find(testSelector('progress-bar'))).to.exist; + let progressWidth = parseInt(find(testSelector('progress-bar')).style.width); + expect(progressWidth).to.be.above(0); + expect(progressWidth).to.be.below(100); + }, 100); + + await wait(); + + let progressWidth = parseInt(find(testSelector('progress-bar')).style.width); + expect(progressWidth).to.equal(100); + }); + + it('yields files property', function () { + this.render(hbs` + {{#gh-uploader files=files as |uploader|}} + {{#each uploader.files as |file|}} +
{{file.name}}
+ {{/each}} + {{/gh-uploader}}`); + + this.set('files', [ + createFile(['test'], {name: 'file1.png'}), + createFile(['test'], {name: 'file2.png'}) + ]); + + expect(findAll('.file')[0].textContent).to.equal('file1.png'); + expect(findAll('.file')[1].textContent).to.equal('file2.png'); + }); + + it('can be cancelled', async function () { + stubSuccessfulUpload(server, 200); + this.set('cancelled', sinon.spy()); + this.set('complete', sinon.spy()); + + this.render(hbs` + {{#gh-uploader files=files onCancel=(action cancelled) as |uploader|}} + + {{/gh-uploader}}`); + + this.set('files', [createFile()]); + + run.later(() => { + click('.cancel-button'); + }, 50); + + await wait(); + + expect(this.get('cancelled').calledOnce, 'onCancel triggered').to.be.true; + expect(this.get('complete').notCalled, 'onComplete triggered').to.be.true; + }); + + it('uploads to supplied `uploadUrl`', async function () { + server.post('/ghost/api/v0.1/images/', function () { + return [200, {'Content-Type': 'application/json'}, '"/content/images/test.png"']; + }); + + this.render(hbs`{{#gh-uploader files=files uploadUrl="/images/"}}{{/gh-uploader}}`); + this.set('files', [createFile()]); + await wait(); + + let [lastRequest] = server.handledRequests; + expect(lastRequest.url).to.equal('/ghost/api/v0.1/images/'); + }); + + it('passes supplied paramName in request', async function () { + this.render(hbs`{{#gh-uploader files=files paramName="testupload"}}{{/gh-uploader}}`); + this.set('files', [createFile()]); + await wait(); + + let [lastRequest] = server.handledRequests; + // requestBody is a FormData object + // this will fail in anything other than Chrome and Firefox + // https://developer.mozilla.org/en-US/docs/Web/API/FormData#Browser_compatibility + expect(lastRequest.requestBody.has('testupload')).to.be.true; + }); + }); + + describe('validation', function () { + it('validates file extensions by default', async function () { + this.set('onFailed', sinon.spy()); + + this.render(hbs` + {{#gh-uploader files=files extensions="jpg,jpeg" onFailed=(action onFailed)}}{{/gh-uploader}} + `); + this.set('files', [createFile(['test'], {name: 'test.png'})]); + await wait(); + + let [onFailedResult] = this.get('onFailed').firstCall.args; + expect(onFailedResult.length).to.equal(1); + expect(onFailedResult[0].fileName, 'onFailed file name').to.equal('test.png'); + expect(onFailedResult[0].message, 'onFailed message').to.match(/not supported/); + }); + + it('accepts custom validation method', async function () { + this.set('validate', function (file) { + return `${file.name} failed test validation`; + }); + this.set('onFailed', sinon.spy()); + + this.render(hbs` + {{#gh-uploader files=files validate=(action validate) onFailed=(action onFailed)}}{{/gh-uploader}} + `); + this.set('files', [createFile(['test'], {name: 'test.png'})]); + await wait(); + + let [onFailedResult] = this.get('onFailed').firstCall.args; + expect(onFailedResult.length).to.equal(1); + expect(onFailedResult[0].fileName).to.equal('test.png'); + expect(onFailedResult[0].message).to.equal('test.png failed test validation'); + }); + + it('yields errors when validation fails', async function () { + this.render(hbs` + {{#gh-uploader files=files extensions="jpg,jpeg" as |uploader|}} + {{#each uploader.errors as |error|}} +
{{error.fileName}}
+
{{error.message}}
+ {{/each}} + {{/gh-uploader}} + `); + this.set('files', [createFile(['test'], {name: 'test.png'})]); + await wait(); + + expect(find('.error-fileName').textContent).to.equal('test.png'); + expect(find('.error-message').textContent).to.match(/not supported/); + }); + }); + + describe('server errors', function () { + beforeEach(function () { + stubFailedUpload(server, 500, 'No upload for you'); + }); + + it('triggers onFailed when uploads complete', async function () { + this.set('uploadFailed', sinon.spy()); + this.set('uploadComplete', sinon.spy()); + + this.render(hbs` + {{#gh-uploader + files=files + onFailed=(action uploadFailed) + onComplete=(action uploadComplete)}} + {{/gh-uploader}} + `); + this.set('files', [ + createFile(['test'], {name: 'file1.png'}), + createFile(['test'], {name: 'file2.png'}) + ]); + await wait(); + + expect(this.get('uploadFailed').calledOnce).to.be.true; + expect(this.get('uploadComplete').calledOnce).to.be.true; + + let [failures] = this.get('uploadFailed').firstCall.args; + expect(failures.length).to.equal(2); + expect(failures[0].fileName).to.equal('file1.png'); + expect(failures[0].message).to.equal('Error: No upload for you'); + }); + + it('triggers onUploadFail when each upload fails', async function () { + this.set('uploadFail', sinon.spy()); + + this.render(hbs` + {{#gh-uploader + files=files + onUploadFail=(action uploadFail)}} + {{/gh-uploader}} + `); + this.set('files', [ + createFile(['test'], {name: 'file1.png'}), + createFile(['test'], {name: 'file2.png'}) + ]); + await wait(); + + expect(this.get('uploadFail').calledTwice).to.be.true; + + let [firstFailure] = this.get('uploadFail').firstCall.args; + expect(firstFailure.fileName).to.equal('file1.png'); + expect(firstFailure.message).to.equal('Error: No upload for you'); + + let [secondFailure] = this.get('uploadFail').secondCall.args; + expect(secondFailure.fileName).to.equal('file2.png'); + expect(secondFailure.message).to.equal('Error: No upload for you'); + }); + + it('yields errors when uploads fail', async function () { + this.render(hbs` + {{#gh-uploader files=files as |uploader|}} + {{#each uploader.errors as |error|}} +
{{error.fileName}}
+
{{error.message}}
+ {{/each}} + {{/gh-uploader}} + `); + this.set('files', [createFile(['test'], {name: 'test.png'})]); + await wait(); + + expect(find('.error-fileName').textContent).to.equal('test.png'); + expect(find('.error-message').textContent).to.equal('Error: No upload for you'); + }); }); });