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

Commit 377c875

Browse files
authored
Merge pull request #163 from ckeditor/t/162
Feature: Introduced `ObservableMixin#decorate()` and support for setting `EmitterMixin#fire()`'s return value by listeners. Closes #162.
2 parents d3e4c1c + edfa6d2 commit 377c875

File tree

5 files changed

+295
-8
lines changed

5 files changed

+295
-8
lines changed

src/emittermixin.js

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,23 @@ const _emitterId = Symbol( 'emitterId' );
2222
*/
2323
const EmitterMixin = {
2424
/**
25-
* Registers a callback function to be executed when an event is fired. Events can be grouped in namespaces using `:`.
25+
* Registers a callback function to be executed when an event is fired.
26+
*
27+
* Events can be grouped in namespaces using `:`.
2628
* When namespaced event is fired, it additionaly fires all callbacks for that namespace.
2729
*
2830
* myEmitter.on( 'myGroup', genericCallback );
2931
* myEmitter.on( 'myGroup:myEvent', specificCallback );
30-
* myEmitter.fire( 'myGroup' ); // genericCallback is fired.
31-
* myEmitter.fire( 'myGroup:myEvent' ); // both genericCallback and specificCallback are fired.
32-
* myEmitter.fire( 'myGroup:foo' ); // genericCallback is fired even though there are no callbacks for "foo".
32+
*
33+
* // genericCallback is fired.
34+
* myEmitter.fire( 'myGroup' );
35+
* // both genericCallback and specificCallback are fired.
36+
* myEmitter.fire( 'myGroup:myEvent' );
37+
* // genericCallback is fired even though there are no callbacks for "foo".
38+
* myEmitter.fire( 'myGroup:foo' );
39+
*
40+
* An event callback can {@link module:utils/eventinfo~EventInfo#stop stop the event} and
41+
* set the {@link module:utils/eventinfo~EventInfo#return return value} of the {@link #fire} method.
3342
*
3443
* @method #on
3544
* @param {String} event The name of the event.
@@ -244,6 +253,9 @@ const EmitterMixin = {
244253
* @method #fire
245254
* @param {String|module:utils/eventinfo~EventInfo} eventOrInfo The name of the event or `EventInfo` object if event is delegated.
246255
* @param {...*} [args] Additional arguments to be passed to the callbacks.
256+
* @returns {*} By default the method returns `undefined`. However, the return value can be changed by listeners
257+
* through modification of the {@link module:utils/eventinfo~EventInfo#return}'s value (the event info
258+
* is the first param of every callback).
247259
*/
248260
fire( eventOrInfo, ...args ) {
249261
const eventInfo = eventOrInfo instanceof EventInfo ? eventOrInfo : new EventInfo( this, eventOrInfo );
@@ -296,6 +308,8 @@ const EmitterMixin = {
296308
fireDelegatedEvents( passAllDestinations, eventInfo, args );
297309
}
298310
}
311+
312+
return eventInfo.return;
299313
},
300314

301315
/**

src/eventinfo.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,5 +58,22 @@ export default class EventInfo {
5858
* @method #off
5959
*/
6060
this.off = spy();
61+
62+
/**
63+
* The value which will be returned by {@link module:utils/emittermixin~EmitterMixin#fire}.
64+
*
65+
* It's `undefined` by default and can be changed by an event listener:
66+
*
67+
* dataController.fire( 'getSelectedContent', ( evt ) => {
68+
* // This listener will make `dataController.fire( 'getSelectedContent' )`
69+
* // always return an empty DocumentFragment.
70+
* evt.return = new DocumentFragment();
71+
*
72+
* // Make sure no other listeners are executed.
73+
* evt.stop();
74+
* } );
75+
*
76+
* @member #return
77+
*/
6178
}
6279
}

src/observablemixin.js

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,88 @@ const ObservableMixin = {
247247
boundObservables.clear();
248248
boundAttributes.clear();
249249
}
250+
},
251+
252+
/**
253+
* Turns the given methods of this object into event-based ones. This means that the new method will fire an event
254+
* (named after the method) and the original action will be plugged as a listener to that event.
255+
*
256+
* This is a very simplified method decoration. Itself it doesn't change the behavior of a method (expect adding the event),
257+
* but it allows to modify it later on by listening to the method's event.
258+
*
259+
* For example, in order to cancel the method execution one can stop the event:
260+
*
261+
* class Foo {
262+
* constructor() {
263+
* this.decorate( 'method' );
264+
* }
265+
*
266+
* method() {
267+
* console.log( 'called!' );
268+
* }
269+
* }
270+
*
271+
* const foo = new Foo();
272+
* foo.on( 'method', ( evt ) => {
273+
* evt.stop();
274+
* }, { priority: 'high' } );
275+
*
276+
* foo.method(); // Nothing is logged.
277+
*
278+
*
279+
* Note: we used a high priority listener here to execute this callback before the one which
280+
* calls the orignal method (which used the default priority).
281+
*
282+
* It's also possible to change the return value:
283+
*
284+
* foo.on( 'method', ( evt ) => {
285+
* evt.return = 'Foo!';
286+
* } );
287+
*
288+
* foo.method(); // -> 'Foo'
289+
*
290+
* Finally, it's possible to access and modify the parameters:
291+
*
292+
* method( a, b ) {
293+
* console.log( `${ a }, ${ b }` );
294+
* }
295+
*
296+
* // ...
297+
*
298+
* foo.on( 'method', ( evt, args ) => {
299+
* args[ 0 ] = 3;
300+
*
301+
* console.log( args[ 1 ] ); // -> 2
302+
* }, { priority: 'high' } );
303+
*
304+
* foo.method( 1, 2 ); // -> '3, 2'
305+
*
306+
* @param {String} methodName Name of the method to decorate.
307+
*/
308+
decorate( methodName ) {
309+
const originalMethod = this[ methodName ];
310+
311+
if ( !originalMethod ) {
312+
/**
313+
* Cannot decorate an undefined method.
314+
*
315+
* @error observablemixin-cannot-decorate-undefined
316+
* @param {Object} object The object which method should be decorated.
317+
* @param {String} methodName Name of the method which does not exist.
318+
*/
319+
throw new CKEditorError(
320+
'observablemixin-cannot-decorate-undefined: Cannot decorate an undefined method.',
321+
{ object: this, methodName }
322+
);
323+
}
324+
325+
this.on( methodName, ( evt, args ) => {
326+
evt.return = originalMethod.apply( this, args );
327+
} );
328+
329+
this[ methodName ] = function( ...args ) {
330+
return this.fire( methodName, args );
331+
};
250332
}
251333

252334
/**

tests/emittermixin.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,72 @@ describe( 'EmitterMixin', () => {
172172
sinon.assert.calledThrice( spyFoo );
173173
sinon.assert.calledThrice( spyFoo2 );
174174
} );
175+
176+
describe( 'return value', () => {
177+
it( 'is undefined by default', () => {
178+
expect( emitter.fire( 'foo' ) ).to.be.undefined;
179+
} );
180+
181+
it( 'is undefined if none of the listeners modified EventInfo#return', () => {
182+
emitter.on( 'foo', () => {} );
183+
184+
expect( emitter.fire( 'foo' ) ).to.be.undefined;
185+
} );
186+
187+
it( 'equals EventInfo#return\'s value', () => {
188+
emitter.on( 'foo', evt => {
189+
evt.return = 1;
190+
} );
191+
192+
expect( emitter.fire( 'foo' ) ).to.equal( 1 );
193+
} );
194+
195+
it( 'equals EventInfo#return\'s value even if the event was stopped', () => {
196+
emitter.on( 'foo', evt => {
197+
evt.return = 1;
198+
} );
199+
emitter.on( 'foo', evt => {
200+
evt.stop();
201+
} );
202+
203+
expect( emitter.fire( 'foo' ) ).to.equal( 1 );
204+
} );
205+
206+
it( 'equals EventInfo#return\'s value when it was set in a namespaced event', () => {
207+
emitter.on( 'foo', evt => {
208+
evt.return = 1;
209+
} );
210+
211+
expect( emitter.fire( 'foo:bar' ) ).to.equal( 1 );
212+
} );
213+
214+
// Rationale – delegation keeps the listeners of the two objects separate.
215+
// E.g. the emitterB's listeners will always be executed before emitterA's ones.
216+
// Hence, values should not be shared either.
217+
it( 'is not affected by listeners executed on emitter to which the event was delegated', () => {
218+
const emitterA = getEmitterInstance();
219+
const emitterB = getEmitterInstance();
220+
221+
emitterB.delegate( 'foo' ).to( emitterA );
222+
223+
emitterA.on( 'foo', evt => {
224+
evt.return = 1;
225+
} );
226+
227+
expect( emitterB.fire( 'foo' ) ).to.be.undefined;
228+
} );
229+
230+
it( 'equals the value set by the last callback', () => {
231+
emitter.on( 'foo', evt => {
232+
evt.return = 1;
233+
} );
234+
emitter.on( 'foo', evt => {
235+
evt.return = 2;
236+
}, { priority: 'high' } );
237+
238+
expect( emitter.fire( 'foo' ) ).to.equal( 1 );
239+
} );
240+
} );
175241
} );
176242

177243
describe( 'on', () => {

tests/observablemixin.js

Lines changed: 112 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ describe( 'Observable', () => {
6161
expect( car.color ).to.equal( 'blue' );
6262
} );
6363

64-
describe( 'set', () => {
64+
describe( 'set()', () => {
6565
it( 'should work when passing an object', () => {
6666
car.set( {
6767
color: 'blue', // Override
@@ -185,7 +185,7 @@ describe( 'Observable', () => {
185185
} );
186186
} );
187187

188-
describe( 'bind', () => {
188+
describe( 'bind()', () => {
189189
it( 'should chain for a single attribute', () => {
190190
expect( car.bind( 'color' ) ).to.contain.keys( 'to' );
191191
} );
@@ -225,7 +225,7 @@ describe( 'Observable', () => {
225225
} ).to.throw( CKEditorError, /observable-bind-rebind/ );
226226
} );
227227

228-
describe( 'to', () => {
228+
describe( 'to()', () => {
229229
it( 'should not chain', () => {
230230
expect(
231231
car.bind( 'color' ).to( new Observable( { color: 'red' } ) )
@@ -732,7 +732,7 @@ describe( 'Observable', () => {
732732
} );
733733
} );
734734

735-
describe( 'unbind', () => {
735+
describe( 'unbind()', () => {
736736
it( 'should not fail when unbinding a fresh observable', () => {
737737
const observable = new Observable();
738738

@@ -811,4 +811,112 @@ describe( 'Observable', () => {
811811
);
812812
} );
813813
} );
814+
815+
describe( 'decorate()', () => {
816+
it( 'makes the method fire an event', () => {
817+
const spy = sinon.spy();
818+
819+
class Foo extends Observable {
820+
method() {}
821+
}
822+
823+
const foo = new Foo();
824+
825+
foo.decorate( 'method' );
826+
827+
foo.on( 'method', spy );
828+
829+
foo.method( 1, 2 );
830+
831+
expect( spy.calledOnce ).to.be.true;
832+
expect( spy.args[ 0 ][ 1 ] ).to.deep.equal( [ 1, 2 ] );
833+
} );
834+
835+
it( 'executes the original method in a listener with the default priority', () => {
836+
const calls = [];
837+
838+
class Foo extends Observable {
839+
method() {
840+
calls.push( 'original' );
841+
}
842+
}
843+
844+
const foo = new Foo();
845+
846+
foo.decorate( 'method' );
847+
848+
foo.on( 'method', () => calls.push( 'high' ), { priority: 'high' } );
849+
foo.on( 'method', () => calls.push( 'low' ), { priority: 'low' } );
850+
851+
foo.method();
852+
853+
expect( calls ).to.deep.equal( [ 'high', 'original', 'low' ] );
854+
} );
855+
856+
it( 'supports overriding return values', () => {
857+
class Foo extends Observable {
858+
method() {
859+
return 1;
860+
}
861+
}
862+
863+
const foo = new Foo();
864+
865+
foo.decorate( 'method' );
866+
867+
foo.on( 'method', evt => {
868+
expect( evt.return ).to.equal( 1 );
869+
870+
evt.return = 2;
871+
} );
872+
873+
expect( foo.method() ).to.equal( 2 );
874+
} );
875+
876+
it( 'supports overriding arguments', () => {
877+
class Foo extends Observable {
878+
method( a ) {
879+
expect( a ).to.equal( 2 );
880+
}
881+
}
882+
883+
const foo = new Foo();
884+
885+
foo.decorate( 'method' );
886+
887+
foo.on( 'method', ( evt, args ) => {
888+
args[ 0 ] = 2;
889+
}, { priority: 'high' } );
890+
891+
foo.method( 1 );
892+
} );
893+
894+
it( 'supports stopping the event (which prevents execution of the orignal method', () => {
895+
class Foo extends Observable {
896+
method() {
897+
throw new Error( 'this should not be executed' );
898+
}
899+
}
900+
901+
const foo = new Foo();
902+
903+
foo.decorate( 'method' );
904+
905+
foo.on( 'method', evt => {
906+
evt.stop();
907+
}, { priority: 'high' } );
908+
909+
foo.method();
910+
} );
911+
912+
it( 'throws when trying to decorate non existing method', () => {
913+
class Foo extends Observable {}
914+
915+
const foo = new Foo();
916+
917+
expect( () => {
918+
foo.decorate( 'method' );
919+
} ).to.throw( CKEditorError, /^observablemixin-cannot-decorate-undefined:/ );
920+
} );
921+
} );
814922
} );

0 commit comments

Comments
 (0)