diff --git a/src/components/legend/draw.js b/src/components/legend/draw.js index 891e2387c4b..3e9b455f612 100644 --- a/src/components/legend/draw.js +++ b/src/components/legend/draw.js @@ -13,6 +13,7 @@ var d3 = require('d3'); var Lib = require('../../lib'); var Plots = require('../../plots/plots'); var Registry = require('../../registry'); +var Events = require('../../lib/events'); var dragElement = require('../dragelement'); var Drawing = require('../drawing'); var Color = require('../color'); @@ -347,22 +348,53 @@ module.exports = function draw(gd) { e.clientY >= bbox.top && e.clientY <= bbox.bottom); }); if(clickedTrace.size() > 0) { - if(numClicks === 1) { - legend._clickTimeout = setTimeout(function() { - handleClick(clickedTrace, gd, numClicks); - }, DBLCLICKDELAY); - } else if(numClicks === 2) { - if(legend._clickTimeout) { - clearTimeout(legend._clickTimeout); - } - handleClick(clickedTrace, gd, numClicks); - } + clickOrDoubleClick(gd, legend, clickedTrace, numClicks, e); } } }); } }; +function clickOrDoubleClick(gd, legend, legendItem, numClicks, evt) { + var trace = legendItem.data()[0][0].trace; + + var evtData = { + event: evt, + node: legendItem.node(), + curveNumber: trace.index, + expandedIndex: trace._expandedIndex, + data: gd.data, + layout: gd.layout, + frames: gd._transitionData._frames, + config: gd._context, + fullData: gd._fullData, + fullLayout: gd._fullLayout + }; + + if(trace._group) { + evtData.group = trace._group; + } + if(trace.type === 'pie') { + evtData.label = legendItem.datum()[0].label; + } + + var clickVal = Events.triggerHandler(gd, 'plotly_legendclick', evtData); + if(clickVal === false) return; + + if(numClicks === 1) { + legend._clickTimeout = setTimeout(function() { + handleClick(legendItem, gd, numClicks); + }, DBLCLICKDELAY); + } + else if(numClicks === 2) { + if(legend._clickTimeout) clearTimeout(legend._clickTimeout); + gd._legendMouseDownTime = 0; + + var dblClickVal = Events.triggerHandler(gd, 'plotly_legenddoubleclick', evtData); + if(dblClickVal !== false) handleClick(legendItem, gd, numClicks); + } +} + function drawTexts(g, gd, maxLength) { var legendItem = g.data()[0][0]; var fullLayout = gd._fullLayout; @@ -460,15 +492,7 @@ function setupTraceToggle(g, gd) { numClicks = Math.max(numClicks - 1, 1); } - if(numClicks === 1) { - legend._clickTimeout = setTimeout(function() { handleClick(g, gd, numClicks); }, DBLCLICKDELAY); - } else if(numClicks === 2) { - if(legend._clickTimeout) { - clearTimeout(legend._clickTimeout); - } - gd._legendMouseDownTime = 0; - handleClick(g, gd, numClicks); - } + clickOrDoubleClick(gd, legend, g, numClicks, d3.event); }); } diff --git a/src/lib/events.js b/src/lib/events.js index 61b06b4b5cc..df47b3f676b 100644 --- a/src/lib/events.js +++ b/src/lib/events.js @@ -58,7 +58,7 @@ var Events = { plotObj.removeAllListeners = ev.removeAllListeners.bind(ev); /* - * Create funtions for managing internal events. These are *only* triggered + * Create functions for managing internal events. These are *only* triggered * by the mirroring of external events via the emit function. */ plotObj._internalOn = internalEv.on.bind(internalEv); @@ -85,20 +85,17 @@ var Events = { }, /* - * This function behaves like jQueries triggerHandler. It calls + * This function behaves like jQuery's triggerHandler. It calls * all handlers for a particular event and returns the return value * of the LAST handler. This function also triggers jQuery's * triggerHandler for backwards compatibility. - * - * Note: triggerHandler has been recommended for deprecation in v2.0.0, - * so the additional behavior of triggerHandler triggering internal events - * is deliberate excluded in order to avoid reinforcing more usage. */ triggerHandler: function(plotObj, event, data) { var jQueryHandlerValue; var nodeEventHandlerValue; + /* - * If Jquery exists run all its handlers for this event and + * If jQuery exists run all its handlers for this event and * collect the return value of the LAST handler function */ if(typeof jQuery !== 'undefined') { @@ -114,30 +111,41 @@ var Events = { var handlers = ev._events[event]; if(!handlers) return jQueryHandlerValue; - /* - * handlers can be function or an array of functions - */ - if(typeof handlers === 'function') handlers = [handlers]; - var lastHandler = handlers.pop(); - - /* - * Call all the handlers except the last one. - */ - for(var i = 0; i < handlers.length; i++) { - handlers[i](data); + // making sure 'this' is the EventEmitter instance + function apply(handler) { + // The 'once' case, we can't just call handler() as we need + // the return value here. So, + // - remove handler + // - call listener and grab return value! + // - stash 'fired' key to not call handler twice + if(handler.listener) { + ev.removeListener(event, handler.listener); + if(!handler.fired) { + handler.fired = true; + return handler.listener.apply(ev, [data]); + } + } else { + return handler.apply(ev, [data]); + } } - /* - * Now call the final handler and collect its value - */ - nodeEventHandlerValue = lastHandler(data); + // handlers can be function or an array of functions + handlers = Array.isArray(handlers) ? handlers : [handlers]; + + var i; + for(i = 0; i < handlers.length - 1; i++) { + apply(handlers[i]); + } + // now call the final handler and collect its value + nodeEventHandlerValue = apply(handlers[i]); /* - * Return either the jquery handler value if it exists or the - * nodeEventHandler value. Jquery event value superceeds nodejs - * events for backwards compatability reasons. + * Return either the jQuery handler value if it exists or the + * nodeEventHandler value. jQuery event value supersedes nodejs + * events for backwards compatibility reasons. */ - return jQueryHandlerValue !== undefined ? jQueryHandlerValue : + return jQueryHandlerValue !== undefined ? + jQueryHandlerValue : nodeEventHandlerValue; }, diff --git a/test/jasmine/tests/events_test.js b/test/jasmine/tests/events_test.js index d4cc5bf423a..9cb0bfe19b7 100644 --- a/test/jasmine/tests/events_test.js +++ b/test/jasmine/tests/events_test.js @@ -220,6 +220,25 @@ describe('Events', function() { expect(eventBaton).toBe(3); expect(result).toBe('pong'); }); + + it('works with *once* event handlers', function() { + var eventBaton = 0; + + Events.init(plotDiv); + + plotDiv.once('ping', function() { + eventBaton++; + return 'pong'; + }); + + var result = Events.triggerHandler(plotDiv, 'ping'); + expect(result).toBe('pong'); + expect(eventBaton).toBe(1); + + var nop = Events.triggerHandler(plotDiv, 'ping'); + expect(nop).toBeUndefined(); + expect(eventBaton).toBe(1); + }); }); describe('purge', function() { diff --git a/test/jasmine/tests/legend_test.js b/test/jasmine/tests/legend_test.js index 940b5c940a2..b92df5bc529 100644 --- a/test/jasmine/tests/legend_test.js +++ b/test/jasmine/tests/legend_test.js @@ -9,12 +9,11 @@ var helpers = require('@src/components/legend/helpers'); var anchorUtils = require('@src/components/legend/anchor_utils'); var d3 = require('d3'); -var fail = require('../assets/fail_test'); +var failTest = require('../assets/fail_test'); var delay = require('../assets/delay'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); - describe('legend defaults', function() { 'use strict'; @@ -538,7 +537,7 @@ describe('legend relayout update', function() { .then(function() { expect(d3.selectAll('g.legend').size()).toBe(1); }) - .catch(fail) + .catch(failTest) .then(done); }); @@ -575,7 +574,7 @@ describe('legend relayout update', function() { }).then(function() { assertLegendStyle('rgb(0, 0, 255)', 'rgb(255, 0, 0)', 10); }) - .catch(fail) + .catch(failTest) .then(done); }); }); @@ -907,7 +906,7 @@ describe('legend interaction', function() { .then(function() { assertVisible(gd, [true, true, true, true]); }) - .catch(fail) + .catch(failTest) .then(done); }); }); @@ -1009,7 +1008,7 @@ describe('legend interaction', function() { {target: 2, value: {name: 'hoo'}} ]); assertLabels(['boo~~~', '1 (trace 1)', 'hoo ', ' ', '4 (trace 1)']); - }).catch(fail).then(done); + }).catch(failTest).then(done); }); }); @@ -1035,9 +1034,13 @@ describe('legend interaction', function() { }; } + function extractVisibilities(data) { + return data.map(function(trace) { return trace.visible; }); + } + function assertVisible(expectation) { return function() { - var actual = gd._fullData.map(function(trace) { return trace.visible; }); + var actual = extractVisibilities(gd._fullData); expect(actual).toEqual(expectation); }; } @@ -1056,7 +1059,7 @@ describe('legend interaction', function() { .then(assertVisible([false, 'legendonly', true])) .then(click(0)) .then(assertVisible([false, true, true])) - .catch(fail).then(done); + .catch(failTest).then(done); }); it('clicking once toggles true -> legendonly', function(done) { @@ -1064,7 +1067,7 @@ describe('legend interaction', function() { .then(assertVisible([false, 'legendonly', true])) .then(click(1)) .then(assertVisible([false, 'legendonly', 'legendonly'])) - .catch(fail).then(done); + .catch(failTest).then(done); }); it('double-clicking isolates a visible trace ', function(done) { @@ -1073,14 +1076,14 @@ describe('legend interaction', function() { .then(assertVisible([false, true, true])) .then(click(0, 2)) .then(assertVisible([false, true, 'legendonly'])) - .catch(fail).then(done); + .catch(failTest).then(done); }); it('double-clicking an isolated trace shows all non-hidden traces', function(done) { Promise.resolve() .then(click(0, 2)) .then(assertVisible([false, true, true])) - .catch(fail).then(done); + .catch(failTest).then(done); }); }); @@ -1110,7 +1113,7 @@ describe('legend interaction', function() { .then(assertVisible([false, 'legendonly', true, 'legendonly'])) .then(click(1)) .then(assertVisible([false, true, true, true])) - .catch(fail).then(done); + .catch(failTest).then(done); }); it('isolates legendgroups as a whole', function(done) { @@ -1119,7 +1122,7 @@ describe('legend interaction', function() { .then(assertVisible([false, true, 'legendonly', true])) .then(click(1, 2)) .then(assertVisible([false, true, true, true])) - .catch(fail).then(done); + .catch(failTest).then(done); }); }); @@ -1152,7 +1155,7 @@ describe('legend interaction', function() { it('computes the initial visibility correctly', function(done) { Promise.resolve() .then(assertVisible([false, true, true, true, true, true, true, true])) - .catch(fail).then(done); + .catch(failTest).then(done); }); it('toggles the visibility of a non-groupby trace in the presence of groupby traces', function(done) { @@ -1161,7 +1164,7 @@ describe('legend interaction', function() { .then(assertVisible([false, true, 'legendonly', true, true, true, true, true])) .then(click(1)) .then(assertVisible([false, true, true, true, true, true, true, true])) - .catch(fail).then(done); + .catch(failTest).then(done); }); it('toggles the visibility of the first group in a groupby trace', function(done) { @@ -1170,7 +1173,7 @@ describe('legend interaction', function() { .then(assertVisible([false, 'legendonly', true, true, true, true, true, true])) .then(click(0)) .then(assertVisible([false, true, true, true, true, true, true, true])) - .catch(fail).then(done); + .catch(failTest).then(done); }); it('toggles the visibility of the third group in a groupby trace', function(done) { @@ -1179,7 +1182,7 @@ describe('legend interaction', function() { .then(assertVisible([false, true, true, true, 'legendonly', true, true, true])) .then(click(3)) .then(assertVisible([false, true, true, true, true, true, true, true])) - .catch(fail).then(done); + .catch(failTest).then(done); }); it('double-clicking isolates a non-groupby trace', function(done) { @@ -1188,7 +1191,7 @@ describe('legend interaction', function() { .then(assertVisible([false, true, 'legendonly', 'legendonly', 'legendonly', 'legendonly', 'legendonly', 'legendonly'])) .then(click(0, 2)) .then(assertVisible([false, true, true, true, true, true, true, true])) - .catch(fail).then(done); + .catch(failTest).then(done); }); it('double-clicking isolates a groupby trace', function(done) { @@ -1197,7 +1200,239 @@ describe('legend interaction', function() { .then(assertVisible([false, 'legendonly', true, 'legendonly', 'legendonly', 'legendonly', 'legendonly', 'legendonly'])) .then(click(1, 2)) .then(assertVisible([false, true, true, true, true, true, true, true])) - .catch(fail).then(done); + .catch(failTest).then(done); + }); + }); + + describe('custom legend click/doubleclick handlers', function() { + var fig, to; + + beforeEach(function() { + fig = Lib.extendDeep({}, require('@mocks/0.json')); + }); + + afterEach(function() { + clearTimeout(to); + }); + + function setupFail() { + to = setTimeout(function() { + fail('did not trigger plotly_legendclick'); + }, 2 * DBLCLICKDELAY); + } + + it('should call custom click handler before default handler', function(done) { + Plotly.newPlot(gd, fig).then(function() { + var gotCalled = false; + + gd.on('plotly_legendclick', function(d) { + gotCalled = true; + expect(extractVisibilities(d.fullData)).toEqual([true, true, true]); + expect(extractVisibilities(gd._fullData)).toEqual([true, true, true]); + }); + gd.on('plotly_restyle', function() { + expect(extractVisibilities(gd._fullData)).toEqual([true, 'legendonly', true]); + if(gotCalled) done(); + }); + setupFail(); + }) + .then(click(1, 1)) + .catch(failTest); + }); + + it('should call custom doubleclick handler before default handler', function(done) { + Plotly.newPlot(gd, fig).then(function() { + var gotCalled = false; + + gd.on('plotly_legenddoubleclick', function(d) { + gotCalled = true; + expect(extractVisibilities(d.fullData)).toEqual([true, true, true]); + expect(extractVisibilities(gd._fullData)).toEqual([true, true, true]); + }); + gd.on('plotly_restyle', function() { + expect(extractVisibilities(gd._fullData)).toEqual(['legendonly', true, 'legendonly']); + if(gotCalled) done(); + }); + setupFail(); + }) + .then(click(1, 2)) + .catch(failTest); + }); + + it('should not call default click handler if custom handler return *false*', function(done) { + Plotly.newPlot(gd, fig).then(function() { + gd.on('plotly_legendclick', function(d) { + Plotly.relayout(gd, 'title', 'just clicked on trace #' + d.curveNumber); + return false; + }); + gd.on('plotly_relayout', function(d) { + expect(typeof d).toBe('object'); + expect(d.title).toBe('just clicked on trace #2'); + done(); + }); + gd.on('plotly_restyle', function() { + fail('should not have triggered plotly_restyle'); + }); + setupFail(); + }) + .then(click(2, 1)) + .catch(failTest); + }); + + it('should not call default doubleclick handle if custom handler return *false*', function(done) { + Plotly.newPlot(gd, fig).then(function() { + gd.on('plotly_legenddoubleclick', function(d) { + Plotly.relayout(gd, 'title', 'just double clicked on trace #' + d.curveNumber); + return false; + }); + gd.on('plotly_relayout', function(d) { + expect(typeof d).toBe('object'); + expect(d.title).toBe('just double clicked on trace #0'); + done(); + }); + gd.on('plotly_restyle', function() { + fail('should not have triggered plotly_restyle'); + }); + setupFail(); + }) + .then(click(0, 2)) + .catch(failTest); + }); + }); + + describe('legend click/doubleclick event data', function() { + function _assert(act, exp) { + for(var k in exp) { + if(k === 'event' || k === 'node') { + expect(act[k]).toBeDefined(); + } else if(k === 'group') { + expect(act[k]).toEqual(exp[k]); + } else { + expect(act[k]).toBe(exp[k], 'key ' + k); + } + } + + expect(Object.keys(act).length) + .toBe(Object.keys(exp).length, '# of keys'); + } + + function clickAndCheck(clickArg, exp) { + Lib.extendFlat(exp, { + event: true, + node: true, + data: gd.data, + layout: gd.layout, + frames: gd._transitionData._frames, + config: gd._context, + fullData: gd._fullData, + fullLayout: gd._fullLayout + }); + + var evtName = { + 1: 'plotly_legendclick', + 2: 'plotly_legenddoubleclick' + }[clickArg[1]]; + + return new Promise(function(resolve, reject) { + var hasBeenCalled = false; + + var to = setTimeout(function() { + reject('did not trigger ' + evtName); + }, 2 * DBLCLICKDELAY); + + function done() { + if(hasBeenCalled) { + clearTimeout(to); + resolve(); + } + } + + gd.once(evtName, function(d) { + hasBeenCalled = true; + _assert(d, exp); + }); + + gd.once('plotly_restyle', done); + gd.once('plotly_relayout', done); + + click(clickArg[0], clickArg[1])(); + }); + } + + it('should have correct keys (base case)', function(done) { + Plotly.newPlot(gd, [{ + x: [1, 2, 3, 4, 5], + y: [1, 2, 1, 2, 3] + }], { + showlegend: true + }) + .then(function() { + return clickAndCheck([0, 1], { + curveNumber: 0, + expandedIndex: 0 + }); + }) + .then(function() { + return clickAndCheck([0, 2], { + curveNumber: 0, + expandedIndex: 0 + }); + }) + .catch(failTest) + .then(done); + }); + + it('should have correct keys (groupby case)', function(done) { + Plotly.newPlot(gd, [{ + x: [1, 2, 3, 4, 5], + y: [1, 2, 1, 2, 3], + transforms: [{ + type: 'groupby', + groups: ['a', 'b', 'b', 'a', 'b'] + }] + }, { + x: [1, 2, 3, 4, 5], + y: [1, 2, 1, 2, 3], + }]) + .then(function() { + return clickAndCheck([1, 1], { + curveNumber: 0, + expandedIndex: 1, + group: 'b' + }); + }) + .then(function() { + return clickAndCheck([2, 2], { + curveNumber: 1, + expandedIndex: 2 + }); + }) + .catch(failTest) + .then(done); + }); + + it('should have correct keys (pie case)', function(done) { + Plotly.newPlot(gd, [{ + type: 'pie', + labels: ['A', 'B', 'C', 'D'], + values: [1, 2, 1, 3] + }]) + .then(function() { + return clickAndCheck([0, 1], { + curveNumber: 0, + expandedIndex: 0, + label: 'D' + }); + }) + .then(function() { + return clickAndCheck([2, 2], { + curveNumber: 0, + expandedIndex: 0, + label: 'A' + }); + }) + .catch(failTest) + .then(done); }); }); });