Skip to content
This repository has been archived by the owner on Jun 26, 2020. It is now read-only.

Commit

Permalink
Merge pull request #127 from ckeditor/t/126
Browse files Browse the repository at this point in the history
Feature: Introduced PendingActions plugin. Closes #126.
  • Loading branch information
Piotr Jasiun authored May 15, 2018
2 parents 7e7b950 + 7da48ce commit e1af648
Show file tree
Hide file tree
Showing 5 changed files with 382 additions and 0 deletions.
150 changes: 150 additions & 0 deletions src/pendingactions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md.
*/

/**
* @module core/pendingactions
*/

import Plugin from './plugin';
import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
import Collection from '@ckeditor/ckeditor5-utils/src/collection';
import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';

/**
* List of editor pending actions.
*
* This plugin should be used to synchronise plugins that execute long-lasting actions
* (i.e. file upload) with the editor integration. It gives a developer, who integrates the editor,
* an easy way to check if there are any pending action whenever such information is needed.
* All plugins, which register pending action provides also a message what action is ongoing
* which can be displayed to a user and let him decide if he wants to interrupt the action or wait.
*
* Adding and updating pending action:
*
* const pendingActions = editor.plugins.get( 'PendingActions' );
* const action = pendingActions.add( 'Upload in progress 0%' );
*
* action.message = 'Upload in progress 10%';
*
* Removing pending action:
*
* const pendingActions = editor.plugins.get( 'PendingActions' );
* const action = pendingActions.add( 'Unsaved changes.' );
*
* pendingActions.remove( action );
*
* Getting pending actions:
*
* const pendingActions = editor.plugins.get( 'PendingActions' );
*
* const action1 = pendingActions.add( 'Action 1' );
* const action2 = pendingActions.add( 'Action 2' );
*
* pendingActions.first // Returns action1
* Array.from( pendingActions ) // Returns [ action1, action2 ]
*
* @extends module:core/plugin~Plugin
*/
export default class PendingActions extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return 'PendingActions';
}

/**
* @inheritDoc
*/
init() {
/**
* Defines whether there is any registered pending action or not.
*
* @readonly
* @observable
* @type {Boolean} #isPending
*/
this.set( 'isPending', false );

/**
* List of pending actions.
*
* @private
* @type {module:utils/collection~Collection}
*/
this._actions = new Collection( { idProperty: '_id' } );
this._actions.delegate( 'add', 'remove' ).to( this );
}

/**
* Adds action to the list of pending actions.
*
* This method returns an action object with observable message property.
* The action object can be later used in the remove method. It also allows you to change the message.
*
* @param {String} message Action message.
* @returns {Object} Observable object that represents a pending action.
*/
add( message ) {
if ( typeof message !== 'string' ) {
/**
* Message has to be a string.
*
* @error pendingactions-add-invalid-message
*/
throw new CKEditorError( 'pendingactions-add-invalid-message: Message has to be a string.' );
}

const action = Object.create( ObservableMixin );

action.set( 'message', message );
this._actions.add( action );
this.isPending = true;

return action;
}

/**
* Removes action from the list of pending actions.
*
* @param {Object} action Action object.
*/
remove( action ) {
this._actions.remove( action );
this.isPending = !!this._actions.length;
}

/**
* Returns first action from the list.
*
* returns {Object} Pending action object.
*/
get first() {
return this._actions.get( 0 );
}

/**
* Iterable interface.
*
* @returns {Iterable.<*>}
*/
[ Symbol.iterator ]() {
return this._actions[ Symbol.iterator ]();
}

/**
* Fired when an action is added to the list.
*
* @event add
* @param {Object} action The added action.
*/

/**
* Fired when an action is removed from the list.
*
* @event remove
* @param {Object} action The removed action.
*/
}
11 changes: 11 additions & 0 deletions tests/manual/pendingactions.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<div>
<button id="add-action">Add pending action</button>
</div>

<h3>Pending actions list:</h3>
<ol class="pending-actions"></ol>

<div id="editor">
<h2>Heading 1</h2>
<p>Paragraph</p>
</div>
80 changes: 80 additions & 0 deletions tests/manual/pendingactions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md.
*/

/* globals console, window, document, setTimeout */

import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';

import ArticlePluginSet from '../_utils/articlepluginset';
import PendingActions from '../../src/pendingactions';

ClassicEditor
.create( document.querySelector( '#editor' ), {
plugins: [ ArticlePluginSet, PendingActions ],
toolbar: [ 'heading', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList', 'blockQuote', 'undo', 'redo' ],
image: {
toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ],
}
} )
.then( editor => {
window.editor = editor;

const pendingActions = editor.plugins.get( PendingActions );
const actionsEl = document.querySelector( '.pending-actions' );

document.querySelector( '#add-action' ).addEventListener( 'click', () => {
const action = pendingActions.add( 'Pending action 0%.' );

wait( 1000 )
.then( () => ( action.message = 'Pending action 0%.' ) )
.then( () => wait( 500 ) )
.then( () => ( action.message = 'Pending action 20%.' ) )
.then( () => wait( 500 ) )
.then( () => ( action.message = 'Pending action 40%.' ) )
.then( () => wait( 500 ) )
.then( () => ( action.message = 'Pending action 60%.' ) )
.then( () => wait( 500 ) )
.then( () => ( action.message = 'Pending action 80%.' ) )
.then( () => wait( 500 ) )
.then( () => ( action.message = 'Pending action 100%.' ) )
.then( () => wait( 500 ) )
.then( () => pendingActions.remove( action ) );
} );

window.addEventListener( 'beforeunload', evt => {
if ( pendingActions.isPending ) {
evt.returnValue = pendingActions.first.message;
}
} );

pendingActions.on( 'add', () => displayActions() );
pendingActions.on( 'remove', () => displayActions() );

function displayActions() {
const frag = document.createDocumentFragment();

for ( const action of pendingActions ) {
const item = document.createElement( 'li' );

item.textContent = action.message;

action.on( 'change:message', () => {
item.textContent = action.message;
} );

frag.appendChild( item );
}

actionsEl.innerHTML = '';
actionsEl.appendChild( frag );
}

function wait( ms ) {
return new Promise( resolve => setTimeout( resolve, ms ) );
}
} )
.catch( err => {
console.error( err.stack );
} );
6 changes: 6 additions & 0 deletions tests/manual/pendingactions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
## Pending actions

1. Click `Add pending actions`
2. Check if action message is changing (action should disappear when progress reaches 100%)
3. Try to reload page or close the browser tab when pending action is displayed, you should see a prompt
4. Try to reload page when there are no pending actions, you shouldn't see a prompt
135 changes: 135 additions & 0 deletions tests/pendingactions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md.
*/

import VirtaulTestEditor from './_utils/virtualtesteditor';
import PendingActions from '../src/pendingactions';
import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';

let editor, pendingActions;

beforeEach( () => {
return VirtaulTestEditor.create( {
plugins: [ PendingActions ],
} ).then( newEditor => {
editor = newEditor;
pendingActions = editor.plugins.get( PendingActions );
} );
} );

afterEach( () => {
return editor.destroy();
} );

describe( 'PendingActions', () => {
it( 'should define static pluginName property', () => {
expect( PendingActions ).to.have.property( 'pluginName', 'PendingActions' );
} );

describe( 'init()', () => {
it( 'should have isPending observable', () => {
const spy = sinon.spy();

pendingActions.on( 'change:isPending', spy );

expect( pendingActions ).to.have.property( 'isPending', false );

pendingActions.isPending = true;

sinon.assert.calledOnce( spy );
} );
} );

describe( 'add()', () => {
it( 'should register and return pending action', () => {
const action = pendingActions.add( 'Action' );

expect( action ).be.an( 'object' );
expect( action.message ).to.equal( 'Action' );
} );

it( 'should return observable', () => {
const spy = sinon.spy();
const action = pendingActions.add( 'Action' );

action.on( 'change:message', spy );

action.message = 'New message';

sinon.assert.calledOnce( spy );
} );

it( 'should update isPending observable', () => {
expect( pendingActions ).to.have.property( 'isPending', false );

pendingActions.add( 'Action' );

expect( pendingActions ).to.have.property( 'isPending', true );
} );

it( 'should throw an error when invalid message is given', () => {
expect( () => {
pendingActions.add( {} );
} ).to.throw( CKEditorError, /^pendingactions-add-invalid-message/ );
} );

it( 'should fire add event with added item', () => {
const spy = sinon.spy();

pendingActions.on( 'add', spy );

const action = pendingActions.add( 'Some action' );

sinon.assert.calledWith( spy, sinon.match.any, action );
} );
} );

describe( 'remove()', () => {
it( 'should remove given pending action and update observable', () => {
const action1 = pendingActions.add( 'Action 1' );
const action2 = pendingActions.add( 'Action 2' );

expect( pendingActions ).to.have.property( 'isPending', true );

pendingActions.remove( action1 );

expect( pendingActions ).to.have.property( 'isPending', true );

pendingActions.remove( action2 );

expect( pendingActions ).to.have.property( 'isPending', false );
} );

it( 'should fire remove event with removed item', () => {
const spy = sinon.spy();

pendingActions.on( 'remove', spy );

const action = pendingActions.add( 'Some action' );

pendingActions.remove( action );

sinon.assert.calledWith( spy, sinon.match.any, action );
} );
} );

describe( 'first', () => {
it( 'should return first pending action from the list', () => {
const action = pendingActions.add( 'Action 1' );

pendingActions.add( 'Action 2' );

expect( pendingActions.first ).to.equal( action );
} );
} );

describe( 'iterator', () => {
it( 'should return all panding actions', () => {
pendingActions.add( 'Action 1' );
pendingActions.add( 'Action 2' );

expect( Array.from( pendingActions, action => action.message ) ).to.have.members( [ 'Action 1', 'Action 2' ] );
} );
} );
} );

0 comments on commit e1af648

Please sign in to comment.