diff --git a/package/spec/editor/models/mixins/configurationContainer-spec.js b/package/spec/editor/models/mixins/configurationContainer-spec.js index c7a8986201..bae64e7cf3 100644 --- a/package/spec/editor/models/mixins/configurationContainer-spec.js +++ b/package/spec/editor/models/mixins/configurationContainer-spec.js @@ -102,6 +102,91 @@ describe('configurationContainer', () => { expect(model.save).toHaveBeenCalled(); }); + it('debounces auto save for rapid changes', () => { + jest.useFakeTimers(); + const Model = Backbone.Model.extend({ + mixins: [configurationContainer({autoSave: true})], + }); + const model = new Model({id: 5, configuration: {some: 'value'}}); + model.save = jest.fn(); + + model.configuration.set('some', 'a'); + model.configuration.set('some', 'b'); + model.configuration.set('some', 'c'); + + expect(model.save).toHaveBeenCalledTimes(1); + + jest.runAllTimers(); + + expect(model.save).toHaveBeenCalledTimes(2); + jest.useRealTimers(); + }); + + it('resets trailing save delay on each new change', () => { + jest.useFakeTimers(); + const Model = Backbone.Model.extend({ + mixins: [configurationContainer({autoSave: true})], + }); + const model = new Model({id: 5, configuration: {some: 'value'}}); + model.save = jest.fn(); + + model.configuration.set('some', 'a'); + expect(model.save).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(300); + model.configuration.set('some', 'b'); + + jest.advanceTimersByTime(300); + expect(model.save).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(200); + expect(model.save).toHaveBeenCalledTimes(2); + jest.useRealTimers(); + }); + + it('does not fire trailing save for single change', () => { + jest.useFakeTimers(); + const Model = Backbone.Model.extend({ + mixins: [configurationContainer({autoSave: true})], + }); + const model = new Model({id: 5, configuration: {some: 'value'}}); + model.save = jest.fn(); + + model.configuration.set('some', 'other value'); + + expect(model.save).toHaveBeenCalledTimes(1); + + jest.runAllTimers(); + + expect(model.save).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + + it('does not fire trailing save for destroyed model', () => { + jest.useFakeTimers(); + const Model = Backbone.Model.extend({ + mixins: [ + configurationContainer({autoSave: true}), + delayedDestroying + ], + }); + const model = new Model({id: 5, configuration: {some: 'value'}}, {urlRoot: '/models'}); + model.save = jest.fn(); + + model.configuration.set('some', 'a'); + model.configuration.set('some', 'b'); + + expect(model.save).toHaveBeenCalledTimes(1); + + model.destroyWithDelay(); + testContext.requests[0].respond(204, { 'Content-Type': 'application/json' }, ''); + + jest.runAllTimers(); + + expect(model.save).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + it('does not auto save new model', () => { const Model = Backbone.Model.extend({ mixins: [configurationContainer({autoSave: true})], diff --git a/package/src/editor/models/mixins/configurationContainer.js b/package/src/editor/models/mixins/configurationContainer.js index 9a7f8872d8..cb2e811306 100644 --- a/package/src/editor/models/mixins/configurationContainer.js +++ b/package/src/editor/models/mixins/configurationContainer.js @@ -44,14 +44,21 @@ export function configurationContainer({configurationModel, autoSave, includeAtt this.configuration = new configurationModel(this.get('configuration')); this.configuration.parent = this; - this.listenTo(this.configuration, 'change', function(model, options) { - if (!this.isNew() && - (!this.isDestroying || !this.isDestroying()) && - (!this.isDestroyed || !this.isDestroyed()) && - autoSave && - options.autoSave !== false) { + const canSave = () => + !this.isNew() && + (!this.isDestroying || !this.isDestroying()) && + (!this.isDestroyed || !this.isDestroyed()); + + const debouncedSave = leadingTrailingDebounce(() => { + if (canSave()) { this.save(); } + }, 500); + + this.listenTo(this.configuration, 'change', function(model, options) { + if (canSave() && autoSave && options.autoSave !== false) { + debouncedSave(); + } this.trigger('change:configuration', this, undefined, options); @@ -81,3 +88,29 @@ export function configurationContainer({configurationModel, autoSave, includeAtt } }; } + +function leadingTrailingDebounce(fn, delay) { + let timer = null; + let pendingTrailing = false; + + function debounced() { + if (timer === null) { + fn(); + } + else { + pendingTrailing = true; + clearTimeout(timer); + } + + timer = setTimeout(() => { + timer = null; + + if (pendingTrailing) { + pendingTrailing = false; + fn(); + } + }, delay); + } + + return debounced; +}