diff --git a/package.json b/package.json index 8a6c361..c01e05d 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ ], "dependencies": { "@ckeditor/ckeditor5-utils": "^10.2.0", - "@ckeditor/ckeditor5-core": "^11.0.0" + "@ckeditor/ckeditor5-core": "^11.0.0", + "lodash-es": "^4.17.10" }, "devDependencies": { "@ckeditor/ckeditor5-engine": "^10.2.0", diff --git a/src/autosave.js b/src/autosave.js index 346fa28..2b0fd09 100644 --- a/src/autosave.js +++ b/src/autosave.js @@ -10,7 +10,9 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; import PendingActions from '@ckeditor/ckeditor5-core/src/pendingactions'; import DomEmitterMixin from '@ckeditor/ckeditor5-utils/src/dom/emittermixin'; -import throttle from './throttle'; +import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin'; +import mix from '@ckeditor/ckeditor5-utils/src/mix'; +import { debounce } from 'lodash-es'; /* globals window */ @@ -62,6 +64,12 @@ export default class Autosave extends Plugin { constructor( editor ) { super( editor ); + const config = editor.config.get( 'autosave' ) || {}; + + // A minimum amount of time that needs to pass after the last action. + // After that time the provided save callbacks are being called. + const waitingTime = config.waitingTime || 1000; + /** * The adapter is an object with a `save()` method. That method will be called whenever * the data changes. It might be called some time after the change, @@ -71,17 +79,31 @@ export default class Autosave extends Plugin { */ /** - * Throttled save method. + * The state of this plugin. + * + * The plugin can be in the following states: + * + * * synchronized - when all changes are saved + * * waiting - when the plugin is waiting for other changes before calling `adapter#save()` and `config.autosave.save()` + * * saving - when the provided save method is called and the plugin waits for the response + * + * @member {'synchronized'|'waiting'|'saving'} #state + */ + this.set( 'state', 'synchronized' ); + + /** + * Debounced save method. The `save` method is called the specified `waitingTime` after the `debouncedSave` is called, + * unless new action happens in the meantime. * - * @protected + * @private * @type {Function} */ - this._throttledSave = throttle( this._save.bind( this ), 1000 ); + this._debouncedSave = debounce( this._save.bind( this ), waitingTime ); /** * Last document version. * - * @protected + * @private * @type {Number} */ this._lastDocumentVersion = editor.model.document.version; @@ -95,12 +117,12 @@ export default class Autosave extends Plugin { this._domEmitter = Object.create( DomEmitterMixin ); /** - * Save action counter monitors number of actions. + * The config of this plugins. * * @private - * @type {Number} + * @type {Object} */ - this._saveActionCounter = 0; + this._config = config; /** * An action that will be added to pending action manager for actions happening in that plugin. @@ -109,14 +131,6 @@ export default class Autosave extends Plugin { * @member {Object} #_action */ - /** - * Plugins' config. - * - * @private - * @type {Object} - */ - this._config = editor.config.get( 'autosave' ) || {}; - /** * Editor's pending actions manager. * @@ -135,13 +149,24 @@ export default class Autosave extends Plugin { this._pendingActions = editor.plugins.get( PendingActions ); this.listenTo( doc, 'change:data', () => { - this._incrementCounter(); + if ( !this._saveCallbacks.length ) { + return; + } + + if ( this.state == 'synchronized' ) { + this._action = this._pendingActions.add( this.editor.t( 'Saving changes' ) ); + this.state = 'waiting'; - const willOriginalFunctionBeCalled = this._throttledSave(); + this._debouncedSave(); + } - if ( !willOriginalFunctionBeCalled ) { - this._decrementCounter(); + else if ( this.state == 'waiting' ) { + this._debouncedSave(); } + + // If the plugin is in `saving` state, it will change its state later basing on the `document.version`. + // If the `document.version` will be higher than stored `#_lastDocumentVersion`, then it means, that some `change:data` + // event has fired in the meantime. } ); // Flush on the editor's destroy listener with the highest priority to ensure that @@ -175,7 +200,7 @@ export default class Autosave extends Plugin { * @protected */ _flush() { - this._throttledSave.flush(); + this._debouncedSave.flush(); } /** @@ -188,74 +213,62 @@ export default class Autosave extends Plugin { _save() { const version = this.editor.model.document.version; - const saveCallbacks = []; - - if ( this.adapter && this.adapter.save ) { - saveCallbacks.push( this.adapter.save ); - } - - if ( this._config.save ) { - saveCallbacks.push( this._config.save ); - } - // Change may not produce an operation, so the document's version // can be the same after that change. if ( version < this._lastDocumentVersion || - !saveCallbacks.length || this.editor.state === 'initializing' ) { - this._throttledSave.flush(); - this._decrementCounter(); + this._debouncedSave.cancel(); return; } this._lastDocumentVersion = version; - // Wait one promise cycle to be sure that: - // 1. The save method is always asynchronous. - // 2. Save callbacks are not called inside conversions or while editor's state changes. + this.state = 'saving'; + + // Wait one promise cycle to be sure that save callbacks are not called + // inside a conversion or when the editor's state changes. Promise.resolve() .then( () => Promise.all( - saveCallbacks.map( cb => cb( this.editor ) ) + this._saveCallbacks.map( cb => cb( this.editor ) ) ) ) .then( () => { - this._decrementCounter(); + if ( this.editor.model.document.version > this._lastDocumentVersion ) { + this.state = 'waiting'; + this._debouncedSave(); + } else { + this.state = 'synchronized'; + this._pendingActions.remove( this._action ); + this._action = null; + } } ); } /** - * Increments counter and adds pending action if it not exists. + * Save callbacks. * * @private + * @type {Array.} */ - _incrementCounter() { - const t = this.editor.t; - - this._saveActionCounter++; + get _saveCallbacks() { + const saveCallbacks = []; - if ( !this._action ) { - this._action = this._pendingActions.add( t( 'Saving changes' ) ); + if ( this.adapter && this.adapter.save ) { + saveCallbacks.push( this.adapter.save ); } - } - /** - * Decrements counter and removes pending action if counter is empty, - * which means, that no new save action occurred. - * - * @private - */ - _decrementCounter() { - this._saveActionCounter--; - - if ( this._saveActionCounter === 0 ) { - this._pendingActions.remove( this._action ); - this._action = null; + if ( this._config.save ) { + saveCallbacks.push( this._config.save ); } + + return saveCallbacks; } } +mix( Autosave, ObservableMixin ); + /** * An interface that requires the `save()` method. * @@ -324,3 +337,22 @@ export default class Autosave extends Plugin { * @param {module:core/editor/editor~Editor} editor The editor instance. * @returns {Promise.<*>} */ + +/** + * The minimum amount of time that need to pass after last action to call the provided callback. + * + * ClassicEditor + * .create( editorElement, { + * autosave: { + * save( editor ) { + * return saveData( editor.getData() ); + * }, + * waitingTime: 2000 + * } + * } ); + * .then( ... ) + * .catch( ... ); + * + * @property module:autosave/autosave~AutosaveConfig#waitingTime + * @type {Number} + */ diff --git a/src/throttle.js b/src/throttle.js deleted file mode 100644 index 36aaf6c..0000000 --- a/src/throttle.js +++ /dev/null @@ -1,68 +0,0 @@ -/** - * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. - * For licensing, see LICENSE.md. - */ - -/** - * @module autosave/throttle - */ - -/* globals window */ - -/** - * Throttle function - a helper that provides ability to specify minimum time gap between calling the original function. - * Comparing to the lodash implementation, this provides an information if calling the throttled function will result in - * calling the original function. - * - * @param {Function} fn Original function that will be called. - * @param {Number} wait Minimum amount of time between original function calls. - */ -export default function throttle( fn, wait ) { - // Time in ms of the last call. - let lastCallTime = 0; - - // Timeout id that enables stopping scheduled call. - let timeoutId = null; - - // @returns {Boolean} `true` if the original function was or will be called. - function throttledFn() { - const now = Date.now(); - - // Cancel call, as the next call is scheduled. - if ( timeoutId ) { - return false; - } - - // Call instantly, as the fn wasn't called within the `time` period. - if ( now > lastCallTime + wait ) { - call(); - return true; - } - - // Set timeout, so the fn will be called `time` ms after the last call. - timeoutId = window.setTimeout( call, lastCallTime + wait - now ); - - return true; - } - - throttledFn.flush = flush; - - function flush() { - if ( timeoutId ) { - window.clearTimeout( timeoutId ); - call(); - } - - lastCallTime = 0; - } - - // Calls the original function and updates internals. - function call() { - lastCallTime = Date.now(); - timeoutId = null; - - fn(); - } - - return throttledFn; -} diff --git a/tests/autosave.js b/tests/autosave.js index e45ddbc..ecbbea8 100644 --- a/tests/autosave.js +++ b/tests/autosave.js @@ -13,9 +13,14 @@ import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import PendingActions from '@ckeditor/ckeditor5-core/src/pendingactions'; describe( 'Autosave', () => { - const sandbox = sinon.sandbox.create( { useFakeTimers: true } ); + const sandbox = sinon.sandbox; + let editor, element, autosave; + beforeEach( () => { + sandbox.useFakeTimers( { now: Date.now() } ); + } ); + afterEach( () => { sandbox.restore(); } ); @@ -39,9 +44,6 @@ describe( 'Autosave', () => { editor = _editor; editor.setData( data ); autosave = editor.plugins.get( Autosave ); - - // Clean autosave's state after setting data. - autosave._flush(); } ); } ); @@ -56,16 +58,22 @@ describe( 'Autosave', () => { } ); it( 'should allow plugin to work without defined adapter and without its config', () => { - editor.model.change( writer => { - writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) ); - editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection ); - } ); + expect( () => { + editor.model.change( writer => { + writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) ); + editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection ); + } ); - autosave._flush(); + sandbox.clock.tick( 1000 ); + } ).to.not.throw(); + } ); + + it( 'should start with the `synchronized` state', () => { + expect( autosave.state ).to.equal( 'synchronized' ); } ); } ); - describe( 'config', () => { + describe( 'config.autosave.save', () => { beforeEach( () => { element = document.createElement( 'div' ); document.body.appendChild( element ); @@ -78,14 +86,12 @@ describe( 'Autosave', () => { } } ) .then( _editor => { - const data = '

paragraph1

paragraph2

'; - editor = _editor; - editor.setData( data ); + autosave = editor.plugins.get( Autosave ); - // Clean autosave's state after setting data. - autosave._flush(); + const data = '

paragraph1

paragraph2

'; + editor.setData( data ); } ); } ); @@ -103,12 +109,14 @@ describe( 'Autosave', () => { editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection ); } ); - return wait().then( () => { + sandbox.clock.tick( 1000 ); + + return Promise.resolve().then( () => { sinon.assert.calledOnce( editor.config.get( 'autosave' ).save ); } ); } ); - it( 'its callback and adapter callback should be called if both are provided', () => { + it( 'config callback and adapter callback should be called if both are provided', () => { editor.config.get( 'autosave' ).save.resetHistory(); autosave.adapter = { @@ -120,7 +128,9 @@ describe( 'Autosave', () => { editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection ); } ); - return wait().then( () => { + sandbox.clock.tick( 1000 ); + + return Promise.resolve().then( () => { sinon.assert.calledOnce( autosave.adapter.save ); sinon.assert.calledOnce( editor.config.get( 'autosave' ).save ); } ); @@ -138,13 +148,98 @@ describe( 'Autosave', () => { editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection ); } ); - return wait().then( () => { + sandbox.clock.tick( 1000 ); + + return Promise.resolve().then( () => { sinon.assert.calledWithExactly( autosave.adapter.save, editor ); sinon.assert.calledWithExactly( editor.config.get( 'autosave' ).save, editor ); } ); } ); } ); + describe( 'config.autosave.waitingTime', () => { + afterEach( () => { + document.body.removeChild( element ); + + return editor.destroy(); + } ); + + it( 'should specify the time of waiting on the next use action before saving', () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); + + return ClassicTestEditor + .create( element, { + plugins: [ Autosave, Paragraph ], + autosave: { + save: sinon.spy(), + waitingTime: 500 + } + } ) + .then( _editor => { + editor = _editor; + + const data = '

paragraph1

paragraph2

'; + editor.setData( data ); + + editor.model.change( writer => { + writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) ); + editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection ); + } ); + + sandbox.clock.tick( 499 ); + + return Promise.resolve().then( () => { + sinon.assert.notCalled( editor.config.get( 'autosave' ).save ); + + sandbox.clock.tick( 1 ); + + return Promise.resolve(); + } ).then( () => { + // Callback should be called exactly after 500ms. + sinon.assert.calledOnce( editor.config.get( 'autosave' ).save ); + } ); + } ); + } ); + + it( 'should be default to 1000', () => { + element = document.createElement( 'div' ); + document.body.appendChild( element ); + + return ClassicTestEditor + .create( element, { + plugins: [ Autosave, Paragraph ], + autosave: { + save: sinon.spy() + } + } ) + .then( _editor => { + editor = _editor; + + const data = '

paragraph1

paragraph2

'; + editor.setData( data ); + + editor.model.change( writer => { + writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) ); + editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection ); + } ); + + sandbox.clock.tick( 999 ); + + return Promise.resolve().then( () => { + sinon.assert.notCalled( editor.config.get( 'autosave' ).save ); + + sandbox.clock.tick( 1 ); + + return Promise.resolve(); + } ).then( () => { + // Callback should be called exactly after 1000ms by default. + sinon.assert.calledOnce( editor.config.get( 'autosave' ).save ); + } ); + } ); + } ); + } ); + describe( 'autosaving', () => { beforeEach( () => { element = document.createElement( 'div' ); @@ -160,9 +255,6 @@ describe( 'Autosave', () => { editor = _editor; editor.setData( data ); autosave = editor.plugins.get( Autosave ); - - // Clean autosave's state after setting data. - autosave._flush(); } ); } ); @@ -182,12 +274,14 @@ describe( 'Autosave', () => { editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection ); } ); - return wait().then( () => { + sandbox.clock.tick( 1000 ); + + return Promise.resolve().then( () => { sinon.assert.calledOnce( autosave.adapter.save ); } ); } ); - it( 'should throttle editor\'s change event', () => { + it( 'should debounce editor\'s change event', () => { const spy = sinon.spy(); const savedStates = []; @@ -199,45 +293,42 @@ describe( 'Autosave', () => { } }; - // Leading (will fire change). editor.model.change( writer => { writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 1 ) ) ); editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection ); } ); - return wait().then( () => { - // Throttled (won't fire change). - editor.model.change( writer => { - writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 1 ) ) ); - editor.model.insertContent( new ModelText( 'bar' ), editor.model.document.selection ); - } ); + sandbox.clock.tick( 1000 ); - return wait(); - } ).then( () => { - // Flushed (will fire change). - editor.model.change( writer => { - writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 1 ) ) ); - editor.model.insertContent( new ModelText( 'biz' ), editor.model.document.selection ); - } ); + editor.model.change( writer => { + writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 1 ) ) ); + editor.model.insertContent( new ModelText( 'bar' ), editor.model.document.selection ); + } ); - autosave._flush(); + sandbox.clock.tick( 1000 ); - return wait(); - } ).then( () => { - expect( spy.callCount ).to.equal( 2 ); + editor.model.change( writer => { + writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 1 ) ) ); + editor.model.insertContent( new ModelText( 'biz' ), editor.model.document.selection ); + } ); + + sinon.assert.notCalled( spy ); + + sandbox.clock.tick( 1000 ); + + return Promise.resolve().then( () => { + sinon.assert.calledOnce( spy ); expect( savedStates ).to.deep.equal( [ - '

paragraph1

foo

', '

paragraph1

biz

' ] ); } ); } ); - it( 'should add a pending action during the saving.', () => { - sandbox.useFakeTimers(); + it( 'should add a pending action after a change and wait on the server response', () => { const pendingActions = editor.plugins.get( PendingActions ); const serverActionSpy = sinon.spy(); const serverActionStub = sinon.stub(); - serverActionStub.onCall( 0 ).resolves( wait( 1000 ).then( serverActionSpy ) ); + serverActionStub.callsFake( () => wait( 1000 ).then( serverActionSpy ) ); autosave.adapter = { save: serverActionStub @@ -253,9 +344,18 @@ describe( 'Autosave', () => { expect( pendingActions.first.message ).to.equal( 'Saving changes' ); sandbox.clock.tick( 1000 ); - return Promise.resolve().then( () => Promise.resolve() ).then( () => { - sinon.assert.calledOnce( serverActionSpy ); - expect( pendingActions.hasAny ).to.be.false; + + sinon.assert.notCalled( serverActionSpy ); + expect( pendingActions.hasAny ).to.be.true; + expect( pendingActions.first.message ).to.equal( 'Saving changes' ); + + return Promise.resolve().then( () => { + sandbox.clock.tick( 1000 ); + + return runPromiseCycles().then( () => { + sinon.assert.calledOnce( serverActionSpy ); + expect( pendingActions.hasAny ).to.be.false; + } ); } ); } ); @@ -276,101 +376,73 @@ describe( 'Autosave', () => { expect( pendingActions.hasAny ).to.be.true; - // Server action needs to wait at least a cycle. - return wait().then( () => { + sandbox.clock.tick( 1000 ); + + return runPromiseCycles().then( () => { sinon.assert.calledOnce( serverActionSpy ); expect( pendingActions.hasAny ).to.be.false; } ); } ); - it( 'should handle correctly throttled save action and preserve pending action until both save actions finish', () => { - sandbox.useFakeTimers(); - const serverActionSpy = sinon.spy(); + it( 'should be in correct states during the saving', () => { const pendingActions = editor.plugins.get( PendingActions ); - - // Create a fake server that responses after 1000ms for the first call and after 1000ms for the second call. + const serverActionSpy = sinon.spy(); const serverActionStub = sinon.stub(); - serverActionStub.onCall( 0 ).resolves( wait( 1000 ).then( serverActionSpy ) ); - serverActionStub.onCall( 1 ).resolves( wait( 2000 ).then( serverActionSpy ) ); + serverActionStub.callsFake( () => wait( 1000 ).then( serverActionSpy ) ); autosave.adapter = { save: serverActionStub }; - expect( pendingActions.hasAny ).to.be.false; - editor.model.change( writer => { writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) ); editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection ); } ); + sinon.assert.notCalled( serverActionSpy ); expect( pendingActions.hasAny ).to.be.true; - - editor.model.change( writer => { - writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) ); - editor.model.insertContent( new ModelText( 'bar' ), editor.model.document.selection ); - } ); - - autosave._flush(); - - expect( pendingActions.hasAny ).to.be.true; + expect( pendingActions.first.message ).to.equal( 'Saving changes' ); sandbox.clock.tick( 1000 ); + expect( autosave.state ).to.equal( 'saving' ); return Promise.resolve().then( () => { - expect( pendingActions.hasAny ).to.be.true; - sinon.assert.calledOnce( serverActionSpy ); + // Add new change before the response from the server. - // Wait another 1000ms and a promise cycle for the second server action. - sandbox.clock.tick( 1000 ); - } ) - .then( () => Promise.resolve() ) - .then( () => { - expect( pendingActions.hasAny ).to.be.false; - sinon.assert.calledTwice( serverActionSpy ); + editor.model.change( writer => { + writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) ); + editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection ); } ); - } ); - - it( 'should handle correctly throttled save action and preserve pending action until both save actions finish #2', () => { - const serverActionSpy = sinon.spy(); - const pendingActions = editor.plugins.get( PendingActions ); - - autosave.adapter = { - save: serverActionSpy - }; - - expect( pendingActions.hasAny ).to.be.false; - editor.model.change( writer => { - writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) ); - editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection ); - } ); - - expect( pendingActions.hasAny ).to.be.true; + sandbox.clock.tick( 1000 ); - editor.model.change( writer => { - writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) ); - editor.model.insertContent( new ModelText( 'bar' ), editor.model.document.selection ); - } ); + return runPromiseCycles(); + } ).then( () => { + // Now there should come the first server response. + expect( autosave.state ).to.equal( 'waiting' ); + expect( pendingActions.hasAny ).to.be.true; + sinon.assert.calledOnce( serverActionSpy ); - expect( pendingActions.hasAny ).to.be.true; + sandbox.clock.tick( 1000 ); - // Server action needs to wait at least a cycle. - return wait().then( () => { - sinon.assert.calledOnce( serverActionSpy ); + return runPromiseCycles(); + } ).then( () => { + expect( autosave.state ).to.equal( 'saving' ); expect( pendingActions.hasAny ).to.be.true; + sinon.assert.calledOnce( serverActionSpy ); - autosave._flush(); + // Wait for the second server response. + sandbox.clock.tick( 1000 ); - // Wait another promise cycle. - return wait().then( () => { - sinon.assert.calledTwice( serverActionSpy ); - expect( pendingActions.hasAny ).to.be.false; - } ); + return runPromiseCycles(); + } ).then( () => { + expect( pendingActions.hasAny ).to.be.false; + expect( autosave.state ).to.equal( 'synchronized' ); + sinon.assert.calledTwice( serverActionSpy ); } ); } ); - it( 'should filter out changes in the selection', () => { + it( 'should filter out selection changes', () => { autosave.adapter = { save: sandbox.spy() }; @@ -379,11 +451,14 @@ describe( 'Autosave', () => { writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) ); } ); - autosave._flush(); - sinon.assert.notCalled( autosave.adapter.save ); + sandbox.clock.tick( 1000 ); + + return runPromiseCycles().then( () => { + sinon.assert.notCalled( autosave.adapter.save ); + } ); } ); - it( 'should filter out markers that does not affect the data model', () => { + it( 'should filter out markers that does not affect the data', () => { autosave.adapter = { save: sandbox.spy() }; @@ -395,18 +470,20 @@ describe( 'Autosave', () => { writer.addMarker( 'name', { usingOperation: true, range } ); } ); - autosave._flush(); + sandbox.clock.tick( 1000 ); editor.model.change( writer => { writer.updateMarker( 'name', { range: range2 } ); } ); - autosave._flush(); + sandbox.clock.tick( 1000 ); - sinon.assert.notCalled( autosave.adapter.save ); + return runPromiseCycles().then( () => { + sinon.assert.notCalled( autosave.adapter.save ); + } ); } ); - it( 'should filter out markers that does not affect the data model #2', () => { + it( 'should filter out markers that does not affect the data #2', () => { autosave.adapter = { save: sandbox.spy() }; @@ -418,45 +495,38 @@ describe( 'Autosave', () => { writer.addMarker( 'name', { usingOperation: false, range } ); } ); - autosave._flush(); + sandbox.clock.tick( 1000 ); editor.model.change( writer => { writer.updateMarker( 'name', { range: range2 } ); } ); - autosave._flush(); + sandbox.clock.tick( 1000 ); - sinon.assert.notCalled( autosave.adapter.save ); + return runPromiseCycles().then( () => { + sinon.assert.notCalled( autosave.adapter.save ); + } ); } ); - it( 'should call the save method when some marker affects the data model', () => { + it( 'should call the save method when some marker affects the data', () => { autosave.adapter = { save: sandbox.spy() }; const range = ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ); - const range2 = ModelRange.createIn( editor.model.document.getRoot().getChild( 1 ) ); editor.model.change( writer => { writer.addMarker( 'name', { usingOperation: true, affectsData: true, range } ); } ); - autosave._flush(); - - return wait().then( () => { - editor.model.change( writer => { - writer.updateMarker( 'name', { range: range2 } ); - } ); - - autosave._flush(); + sandbox.clock.tick( 1000 ); - return wait().then( () => { - sinon.assert.calledTwice( autosave.adapter.save ); - } ); + return runPromiseCycles().then( () => { + sinon.assert.calledOnce( autosave.adapter.save ); } ); } ); - it( 'should call the save method when some marker affects the data model #2', () => { + it( 'should call the save method when some marker affects the data #2', () => { autosave.adapter = { save: sandbox.spy() }; @@ -468,24 +538,24 @@ describe( 'Autosave', () => { writer.addMarker( 'name', { usingOperation: false, affectsData: true, range } ); } ); - autosave._flush(); + sandbox.clock.tick( 1000 ); - return wait().then( () => { + return runPromiseCycles().then( () => { sinon.assert.calledOnce( autosave.adapter.save ); editor.model.change( writer => { writer.updateMarker( 'name', { range: range2 } ); } ); - autosave._flush(); + sandbox.clock.tick( 1000 ); - return wait().then( () => { + return runPromiseCycles().then( () => { sinon.assert.calledTwice( autosave.adapter.save ); } ); } ); } ); - it( 'should call the save method when some marker affects the data model #3', () => { + it( 'should call the save method when some marker affects the data #3', () => { autosave.adapter = { save: sandbox.spy() }; @@ -497,12 +567,14 @@ describe( 'Autosave', () => { writer.addMarker( 'marker-affecting-data', { usingOperation: false, affectsData: false, range } ); } ); - return wait().then( () => { + sandbox.clock.tick( 1000 ); + + return runPromiseCycles().then( () => { sinon.assert.calledOnce( autosave.adapter.save ); } ); } ); - it( 'should flush remaining calls after editor\'s destroy', () => { + it( 'should flush remaining call after editor\'s destroy', () => { const spy = sandbox.spy(); const savedStates = []; @@ -519,24 +591,22 @@ describe( 'Autosave', () => { editor.model.insertContent( new ModelText( 'foo' ), editor.model.document.selection ); } ); - return wait().then( () => { - editor.model.change( writer => { - writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 1 ) ) ); - editor.model.insertContent( new ModelText( 'bar' ), editor.model.document.selection ); - } ); + editor.model.change( writer => { + writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 1 ) ) ); + editor.model.insertContent( new ModelText( 'bar' ), editor.model.document.selection ); + } ); - return editor.destroy().then( () => { - expect( spy.callCount ).to.equal( 2 ); - expect( savedStates ).to.deep.equal( [ - '

foo

paragraph2

', - '

foo

bar

', - ] ); - } ); + sinon.assert.notCalled( spy ); + + return editor.destroy().then( () => { + sinon.assert.calledOnce( spy ); + expect( savedStates ).to.deep.equal( [ + '

foo

bar

', + ] ); } ); } ); it( 'should work after editor\'s destroy with long server\'s response time', () => { - sandbox.useFakeTimers(); const pendingActions = editor.plugins.get( PendingActions ); const serverActionSpy = sinon.spy(); const serverActionStub = sinon.stub(); @@ -565,7 +635,7 @@ describe( 'Autosave', () => { } ); } ); - it( 'should wait on editor initialization', () => { + it( 'should run callbacks until the editor is in the ready state', () => { element = document.createElement( 'div' ); document.body.appendChild( element ); editor = null; @@ -576,16 +646,16 @@ describe( 'Autosave', () => { } init() { - this.editor.once( 'ready', () => { + this.editor.once( 'dataReady', () => { const editor = this.editor; editor.model.change( writer => { writer.setSelection( ModelRange.createIn( editor.model.document.getRoot().getChild( 0 ) ) ); editor.model.insertContent( new ModelText( 'bar' ), editor.model.document.selection ); } ); - } ); - return Promise.resolve().then( () => Promise.resolve() ); + sandbox.clock.tick( 10 ); + } ); } } @@ -593,18 +663,16 @@ describe( 'Autosave', () => { .create( element, { plugins: [ Autosave, Paragraph, AsyncPlugin ], autosave: { - save: sinon.spy( () => { - expect( editor ).to.not.be.null; - } ) + save: sinon.spy(), + waitingTime: 5 } } ) .then( _editor => { editor = _editor; - autosave = editor.plugins.get( Autosave ); const spy = editor.config.get( 'autosave' ).save; expect( editor.getData() ).to.equal( '

bar

' ); - sinon.assert.calledOnce( spy ); + sinon.assert.notCalled( spy ); } ) .then( () => { document.body.removeChild( element ); @@ -619,3 +687,11 @@ function wait( time ) { window.setTimeout( res, time ); } ); } + +function runPromiseCycles() { + return Promise.resolve() + .then( () => Promise.resolve() ) + .then( () => Promise.resolve() ) + .then( () => Promise.resolve() ) + .then( () => Promise.resolve() ); +} diff --git a/tests/manual/autosave.js b/tests/manual/autosave.js index 1b29ca6..d500f93 100644 --- a/tests/manual/autosave.js +++ b/tests/manual/autosave.js @@ -29,17 +29,38 @@ ClassicEditor save() { const data = editor.getData(); - return saveEditorContentToDatabase( data ); + return wait( 1000 ) + .then( () => console.log( `${ getTime() } Saved content: ${ data }` ) ); } }; + + autosave.listenTo( autosave, 'change:state', + ( evt, propName, newValue, oldValue ) => console.log( `${ getTime() } Changed state: ${ oldValue } -> ${ newValue }` ) ); } ); -function saveEditorContentToDatabase( data ) { +function wait( time ) { return new Promise( res => { - window.setTimeout( () => { - console.log( data ); - - res(); - }, 1000 ); + window.setTimeout( res, time ); } ); } + +function getTime() { + const date = new Date(); + + return '[' + + date.getHours() + ':' + + setDigitSize( date.getMinutes(), 2 ) + ':' + + setDigitSize( date.getSeconds(), 2 ) + '.' + + setDigitSize( date.getMilliseconds(), 2 ) + + ']'; +} + +function setDigitSize( number, size ) { + const string = String( number ); + + if ( string.length >= size ) { + return string.slice( 0, size ); + } + + return '0'.repeat( size - string.length ) + string; +} diff --git a/tests/manual/autosave.md b/tests/manual/autosave.md index 3a4cdf3..4dded0e 100644 --- a/tests/manual/autosave.md +++ b/tests/manual/autosave.md @@ -1,5 +1,9 @@ -1. Play with the editor. You should logs of the editor's data in console with 1s timeout (it simulates the back-end). You should not see logs when you changes the selection. +1. Play with the editor. You should logs of the changing autosave's states. You should not see logs when you changes the selection. 1. Type something and quickly try to reload the page. You should see something like this: `Reload site? Changes that you made may not be saved.`. 1. Type something and quickly and click the `destroy editor` button. Most recent changes should be logged to the console. + +1. Type something without big time gaps. Once you stop typing there should be the first `waiting -> saving` and then the response should show up with the whole editor's content. + +1. Type something. Once you'll see the `waiting -> saving` change, type something else. Then you should see the response and the `saving->waiting` change. Then you should see another response from the fake server. diff --git a/tests/throttle.js b/tests/throttle.js deleted file mode 100644 index 7c718d3..0000000 --- a/tests/throttle.js +++ /dev/null @@ -1,143 +0,0 @@ -/** - * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. - * For licensing, see LICENSE.md. - */ - -import throttle from '../src/throttle'; - -describe( 'throttle', () => { - const sandbox = sinon.sandbox.create( { - useFakeTimers: true - } ); - - beforeEach( () => { - sandbox.useFakeTimers( { now: 1000 } ); - } ); - - afterEach( () => { - sandbox.restore(); - } ); - - it( 'should run first call synchronously', () => { - const spy = sinon.spy(); - const throttledFn = throttle( spy, 100 ); - - throttledFn(); - - sinon.assert.calledOnce( spy ); - - sandbox.clock.runAll(); - sinon.assert.calledOnce( spy ); - } ); - - it( 'should run next calls after specified amount of time', () => { - const spy = sinon.spy(); - const throttledFn = throttle( spy, 100 ); - - throttledFn(); - throttledFn(); - - sinon.assert.calledOnce( spy ); - - sandbox.clock.tick( 99 ); - - sinon.assert.calledOnce( spy ); - - sandbox.clock.tick( 1 ); - - sinon.assert.calledTwice( spy ); - } ); - - it( 'should skip the call if another call is scheduled', () => { - const spy = sinon.spy(); - const throttledFn = throttle( spy, 100 ); - - const isFirstInvoked = throttledFn(); - const willSecondInvoke = throttledFn(); - const willThirdInvoke = throttledFn(); - - expect( isFirstInvoked ).to.be.true; - expect( willSecondInvoke ).to.be.true; - expect( willThirdInvoke ).to.be.false; - - sandbox.clock.runAll(); - sinon.assert.calledTwice( spy ); - } ); - - it( 'should call the next call after the specified amount of time', () => { - const spy = sinon.spy(); - const throttledFn = throttle( spy, 100 ); - - throttledFn(); - throttledFn(); - - sandbox.clock.tick( 50 ); - - sinon.assert.calledOnce( spy ); - - sandbox.clock.tick( 50 ); - - sinon.assert.calledTwice( spy ); - - throttledFn(); - - sandbox.clock.tick( 100 ); - - sinon.assert.calledThrice( spy ); - } ); - - describe( 'flush', () => { - it( 'should be provide as a method on the throttled function', () => { - const spy = sinon.spy(); - const throttledFn = throttle( spy, 100 ); - - expect( throttledFn.flush ).to.be.a( 'function' ); - } ); - - it( 'should enable calling the throttled call immediately', () => { - const spy = sinon.spy(); - const throttledFn = throttle( spy, 100 ); - - throttledFn(); - throttledFn(); - - sinon.assert.calledOnce( spy ); - - throttledFn.flush(); - sinon.assert.calledTwice( spy ); - - sandbox.clock.runAll(); - sinon.assert.calledTwice( spy ); - } ); - - it( 'should do nothing if there is no scheduled call', () => { - const spy = sinon.spy(); - const throttledFn = throttle( spy, 100 ); - - throttledFn(); - - sinon.assert.calledOnce( spy ); - - throttledFn.flush(); - sinon.assert.calledOnce( spy ); - - sandbox.clock.runAll(); - sinon.assert.calledOnce( spy ); - } ); - - it( 'should enable calling after the flushed call', () => { - const spy = sinon.spy(); - const throttledFn = throttle( spy, 100 ); - - throttledFn(); - throttledFn(); - throttledFn.flush(); - throttledFn(); - - sinon.assert.calledThrice( spy ); - - sandbox.clock.runAll(); - sinon.assert.calledThrice( spy ); - } ); - } ); -} );