diff --git a/src/plot_api/helpers.js b/src/plot_api/helpers.js index d7b5ea03af8..1753335c49f 100644 --- a/src/plot_api/helpers.js +++ b/src/plot_api/helpers.js @@ -430,3 +430,51 @@ exports.coerceTraceIndices = function(gd, traceIndices) { return traceIndices; }; + +/** + * Manages logic around array container item creation / deletion / update + * that nested property along can't handle. + * + * @param {Object} np + * nested property of update attribute string about trace or layout object + * @param {*} newVal + * update value passed to restyle / relayout / update + * @param {Object} undoit + * undo hash (N.B. undoit may be mutated here). + * + */ +exports.manageArrayContainers = function(np, newVal, undoit) { + var obj = np.obj, + parts = np.parts, + pLength = parts.length, + pLast = parts[pLength - 1]; + + var pLastIsNumber = isNumeric(pLast); + + // delete item + if(pLastIsNumber && newVal === null) { + + // Clear item in array container when new value is null + var contPath = parts.slice(0, pLength - 1).join('.'), + cont = Lib.nestedProperty(obj, contPath).get(); + cont.splice(pLast, 1); + + // Note that nested property clears null / undefined at end of + // array container, but not within them. + } + // create item + else if(pLastIsNumber && np.get() === undefined) { + + // When adding a new item, make sure undo command will remove it + if(np.get() === undefined) undoit[np.astr] = null; + + np.set(newVal); + } + // update item + else { + + // If the last part of attribute string isn't a number, + // np.set is all we need. + np.set(newVal); + } +}; diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index af891ba692d..263c0164a01 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -1414,9 +1414,6 @@ function _restyle(gd, aobj, _traces) { continue; } - // take no chances on transforms - if(ai.substr(0, 10) === 'transforms') flags.docalc = true; - // set attribute in gd.data undoit[ai] = a0(); for(i = 0; i < traces.length; i++) { @@ -1556,6 +1553,10 @@ function _restyle(gd, aobj, _traces) { } helpers.swapXYData(cont); } + else if(Plots.dataArrayContainers.indexOf(param.parts[0]) !== -1) { + helpers.manageArrayContainers(param, newVal, undoit); + flags.docalc = true; + } // all the other ones, just modify that one attribute else param.set(newVal); @@ -1815,8 +1816,7 @@ function _relayout(gd, aobj) { // trunk nodes (everything except the leaf) ptrunk = p.parts.slice(0, pend).join('.'), parentIn = Lib.nestedProperty(gd.layout, ptrunk).get(), - parentFull = Lib.nestedProperty(fullLayout, ptrunk).get(), - diff; + parentFull = Lib.nestedProperty(fullLayout, ptrunk).get(); if(vi === undefined) continue; @@ -1921,6 +1921,9 @@ function _relayout(gd, aobj) { objList = layout[objType] || [], obji = objList[objNum] || {}; + // new API, remove annotation / shape with `null` + if(vi === null) aobj[ai] = 'remove'; + // if p.parts is just an annotation number, and val is either // 'add' or an entire annotation to add, the undo is 'remove' // if val is 'remove' then undo is the whole annotation object @@ -1950,42 +1953,11 @@ function _relayout(gd, aobj) { drawOne(gd, objNum, p.parts.slice(2).join('.'), aobj[ai]); delete aobj[ai]; } - else if(p.parts[0] === 'images') { - var update = Lib.objectFromPath(ai, vi); - Lib.extendDeepAll(gd.layout, update); - - Registry.getComponentMethod('images', 'supplyLayoutDefaults')(gd.layout, gd._fullLayout); - Registry.getComponentMethod('images', 'draw')(gd); - } - else if(p.parts[0] === 'mapbox' && p.parts[1] === 'layers') { - Lib.extendDeepAll(gd.layout, Lib.objectFromPath(ai, vi)); - - // append empty container to mapbox.layers - // so that relinkPrivateKeys does not complain - - var fullLayers = (gd._fullLayout.mapbox || {}).layers || []; - diff = (p.parts[2] + 1) - fullLayers.length; - - for(i = 0; i < diff; i++) fullLayers.push({}); - - flags.doplot = true; - } - else if(p.parts[0] === 'updatemenus') { - Lib.extendDeepAll(gd.layout, Lib.objectFromPath(ai, vi)); - - var menus = gd._fullLayout.updatemenus || []; - diff = (p.parts[2] + 1) - menus.length; - - for(i = 0; i < diff; i++) menus.push({}); - flags.doplot = true; - } - else if(p.parts[0] === 'sliders') { - Lib.extendDeepAll(gd.layout, Lib.objectFromPath(ai, vi)); - - var sliders = gd._fullLayout.sliders || []; - diff = (p.parts[2] + 1) - sliders.length; - - for(i = 0; i < diff; i++) sliders.push({}); + else if( + Plots.layoutArrayContainers.indexOf(p.parts[0]) !== -1 || + (p.parts[0] === 'mapbox' && p.parts[1] === 'layers') + ) { + helpers.manageArrayContainers(p, vi, undoit); flags.doplot = true; } // alter gd.layout diff --git a/src/plots/plots.js b/src/plots/plots.js index 6ca52d59d4e..c23c52dbd26 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -1424,6 +1424,9 @@ plots.extendObjectWithContainers = function(dest, src, containerPaths) { return dest; }; +plots.dataArrayContainers = ['transforms']; +plots.layoutArrayContainers = ['annotations', 'shapes', 'images', 'sliders', 'updatemenus']; + /* * Extend a trace definition. This method: * @@ -1433,7 +1436,7 @@ plots.extendObjectWithContainers = function(dest, src, containerPaths) { * The result is the original object reference with the new contents merged in. */ plots.extendTrace = function(destTrace, srcTrace) { - return plots.extendObjectWithContainers(destTrace, srcTrace, ['transforms']); + return plots.extendObjectWithContainers(destTrace, srcTrace, plots.dataArrayContainers); }; /* @@ -1446,13 +1449,7 @@ plots.extendTrace = function(destTrace, srcTrace) { * The result is the original object reference with the new contents merged in. */ plots.extendLayout = function(destLayout, srcLayout) { - return plots.extendObjectWithContainers(destLayout, srcLayout, [ - 'annotations', - 'shapes', - 'images', - 'sliders', - 'updatemenus' - ]); + return plots.extendObjectWithContainers(destLayout, srcLayout, plots.layoutArrayContainers); }; /** diff --git a/test/jasmine/tests/annotations_test.js b/test/jasmine/tests/annotations_test.js index 2da994bf5f7..72a7ab7675e 100644 --- a/test/jasmine/tests/annotations_test.js +++ b/test/jasmine/tests/annotations_test.js @@ -69,11 +69,18 @@ describe('annotations relayout', function() { expect(countAnnotations()).toEqual(len + 1); return Plotly.relayout(gd, 'annotations[' + 0 + ']', 'remove'); - }).then(function() { + }) + .then(function() { expect(countAnnotations()).toEqual(len); + return Plotly.relayout(gd, 'annotations[' + 0 + ']', null); + }) + .then(function() { + expect(countAnnotations()).toEqual(len - 1); + return Plotly.relayout(gd, { annotations: [] }); - }).then(function() { + }) + .then(function() { expect(countAnnotations()).toEqual(0); done(); diff --git a/test/jasmine/tests/layout_images_test.js b/test/jasmine/tests/layout_images_test.js index 7c6eadbe5bf..b40999cfc1e 100644 --- a/test/jasmine/tests/layout_images_test.js +++ b/test/jasmine/tests/layout_images_test.js @@ -329,18 +329,22 @@ describe('Layout images', function() { return Plotly.relayout(gd, 'images[2]', makeImage(pythonLogo, 0.2, 0.5)); }).then(function() { assertImages(3); + expect(gd.layout.images.length).toEqual(3); - return Plotly.relayout(gd, 'images[2]', 'remove'); + return Plotly.relayout(gd, 'images[2]', null); }).then(function() { assertImages(2); + expect(gd.layout.images.length).toEqual(2); - return Plotly.relayout(gd, 'images[1]', 'remove'); + return Plotly.relayout(gd, 'images[1]', null); }).then(function() { assertImages(1); + expect(gd.layout.images.length).toEqual(1); - return Plotly.relayout(gd, 'images[0]', 'remove'); + return Plotly.relayout(gd, 'images[0]', null); }).then(function() { assertImages(0); + expect(gd.layout.images).toEqual([]); done(); }); diff --git a/test/jasmine/tests/lib_test.js b/test/jasmine/tests/lib_test.js index 815e0fd3b4f..09707dac9aa 100644 --- a/test/jasmine/tests/lib_test.js +++ b/test/jasmine/tests/lib_test.js @@ -1561,23 +1561,27 @@ describe('Queue', function() { Plotly.plot(gd, [{ y: [2, 1, 2] - }]).then(function() { + }]) + .then(function() { expect(gd.undoQueue).toBeUndefined(); return Plotly.restyle(gd, 'marker.color', 'red'); - }).then(function() { + }) + .then(function() { expect(gd.undoQueue.index).toEqual(1); expect(gd.undoQueue.queue[0].undo.args[0][1]['marker.color']).toEqual([undefined]); expect(gd.undoQueue.queue[0].redo.args[0][1]['marker.color']).toEqual('red'); return Plotly.relayout(gd, 'title', 'A title'); - }).then(function() { + }) + .then(function() { expect(gd.undoQueue.index).toEqual(2); expect(gd.undoQueue.queue[1].undo.args[0][1].title).toEqual(undefined); expect(gd.undoQueue.queue[1].redo.args[0][1].title).toEqual('A title'); return Plotly.restyle(gd, 'mode', 'markers'); - }).then(function() { + }) + .then(function() { expect(gd.undoQueue.index).toEqual(2); expect(gd.undoQueue.queue[2]).toBeUndefined(); @@ -1587,6 +1591,38 @@ describe('Queue', function() { expect(gd.undoQueue.queue[0].undo.args[0][1].title).toEqual(undefined); expect(gd.undoQueue.queue[0].redo.args[0][1].title).toEqual('A title'); + return Plotly.restyle(gd, 'transforms[0]', { type: 'filter' }); + }) + .then(function() { + expect(gd.undoQueue.queue[1].undo.args[0][1]) + .toEqual({ 'transforms[0]': null }); + expect(gd.undoQueue.queue[1].redo.args[0][1]) + .toEqual({ 'transforms[0]': { type: 'filter' } }); + + return Plotly.relayout(gd, 'updatemenus[0]', { buttons: [] }); + }) + .then(function() { + expect(gd.undoQueue.queue[1].undo.args[0][1]) + .toEqual({ 'updatemenus[0]': null }); + expect(gd.undoQueue.queue[1].redo.args[0][1]) + .toEqual({ 'updatemenus[0]': { buttons: [] } }); + + return Plotly.relayout(gd, 'updatemenus[0]', null); + }) + .then(function() { + expect(gd.undoQueue.queue[1].undo.args[0][1]) + .toEqual({ 'updatemenus[0]': { buttons: []} }); + expect(gd.undoQueue.queue[1].redo.args[0][1]) + .toEqual({ 'updatemenus[0]': null }); + + return Plotly.restyle(gd, 'transforms[0]', null); + }) + .then(function() { + expect(gd.undoQueue.queue[1].undo.args[0][1]) + .toEqual({ 'transforms[0]': [ { type: 'filter' } ]}); + expect(gd.undoQueue.queue[1].redo.args[0][1]) + .toEqual({ 'transforms[0]': null }); + done(); }); }); diff --git a/test/jasmine/tests/mapbox_test.js b/test/jasmine/tests/mapbox_test.js index 5d29754ba3f..6d0f6009987 100644 --- a/test/jasmine/tests/mapbox_test.js +++ b/test/jasmine/tests/mapbox_test.js @@ -548,18 +548,25 @@ describe('mapbox plots', function() { expect(countVisibleLayers(gd)).toEqual(0); Plotly.relayout(gd, 'mapbox.layers[0]', layer0).then(function() { + expect(gd.layout.mapbox.layers.length).toEqual(1); expect(countVisibleLayers(gd)).toEqual(1); return Plotly.relayout(gd, 'mapbox.layers[1]', layer1); - }).then(function() { + }) + .then(function() { + expect(gd.layout.mapbox.layers.length).toEqual(2); expect(countVisibleLayers(gd)).toEqual(2); return Plotly.relayout(gd, mapUpdate); - }).then(function() { + }) + .then(function() { + expect(gd.layout.mapbox.layers.length).toEqual(2); expect(countVisibleLayers(gd)).toEqual(2); return Plotly.relayout(gd, styleUpdate0); - }).then(function() { + }) + .then(function() { + expect(gd.layout.mapbox.layers.length).toEqual(2); expect(countVisibleLayers(gd)).toEqual(2); return assertLayerStyle(gd, { @@ -567,11 +574,15 @@ describe('mapbox plots', function() { 'fill-outline-color': [0, 0, 1, 1], 'fill-opacity': 0.3 }, 0); - }).then(function() { + }) + .then(function() { + expect(gd.layout.mapbox.layers.length).toEqual(2); expect(countVisibleLayers(gd)).toEqual(2); return Plotly.relayout(gd, styleUpdate1); - }).then(function() { + }) + .then(function() { + expect(gd.layout.mapbox.layers.length).toEqual(2); expect(countVisibleLayers(gd)).toEqual(2); return assertLayerStyle(gd, { @@ -579,25 +590,35 @@ describe('mapbox plots', function() { 'line-color': [0, 0, 1, 1], 'line-opacity': 0.6 }, 1); - }).then(function() { + }) + .then(function() { + expect(gd.layout.mapbox.layers.length).toEqual(2); expect(countVisibleLayers(gd)).toEqual(2); - return Plotly.relayout(gd, 'mapbox.layers[1]', 'remove'); - }).then(function() { + return Plotly.relayout(gd, 'mapbox.layers[1]', null); + }) + .then(function() { + expect(gd.layout.mapbox.layers.length).toEqual(1); expect(countVisibleLayers(gd)).toEqual(1); - return Plotly.relayout(gd, 'mapbox.layers[0]', 'remove'); - }).then(function() { + return Plotly.relayout(gd, 'mapbox.layers[0]', null); + }) + .then(function() { + expect(gd.layout.mapbox.layers.length).toEqual(0); expect(countVisibleLayers(gd)).toEqual(0); return Plotly.relayout(gd, 'mapbox.layers[0]', {}); - }).then(function() { + }) + .then(function() { + expect(gd.layout.mapbox.layers).toEqual([]); expect(countVisibleLayers(gd)).toEqual(0); // layer with no source are not drawn return Plotly.relayout(gd, 'mapbox.layers[0].source', layer0.source); - }).then(function() { + }) + .then(function() { + expect(gd.layout.mapbox.layers.length).toEqual(1); expect(countVisibleLayers(gd)).toEqual(1); done(); diff --git a/test/jasmine/tests/shapes_test.js b/test/jasmine/tests/shapes_test.js index b2058b03bc4..1eb8dab4ce1 100644 --- a/test/jasmine/tests/shapes_test.js +++ b/test/jasmine/tests/shapes_test.js @@ -183,12 +183,21 @@ describe('Test shapes:', function() { expect(countShapePathsInUpperLayer()).toEqual(pathCount + 1); expect(getLastShape(gd)).toEqual(shape); expect(countShapes(gd)).toEqual(index + 1); - }).then(function() { - Plotly.relayout(gd, 'shapes[' + index + ']', 'remove'); - }).then(function() { + }) + .then(function() { + return Plotly.relayout(gd, 'shapes[' + index + ']', 'remove'); + }) + .then(function() { expect(countShapePathsInUpperLayer()).toEqual(pathCount); expect(countShapes(gd)).toEqual(index); - }).then(done); + + return Plotly.relayout(gd, 'shapes[' + 1 + ']', null); + }) + .then(function() { + expect(countShapePathsInUpperLayer()).toEqual(pathCount - 1); + expect(countShapes(gd)).toEqual(index - 1); + }) + .then(done); }); it('should be able to remove all shapes', function(done) { diff --git a/test/jasmine/tests/sliders_test.js b/test/jasmine/tests/sliders_test.js index f23a6926f4e..7fb0af5db34 100644 --- a/test/jasmine/tests/sliders_test.js +++ b/test/jasmine/tests/sliders_test.js @@ -211,6 +211,7 @@ describe('update sliders interactions', function() { assertNodeCount('.' + constants.groupClassName, 1); expect(gd._fullLayout._pushmargin['slider-0']).toBeUndefined(); expect(gd._fullLayout._pushmargin['slider-1']).toBeDefined(); + expect(gd.layout.sliders.length).toEqual(2); return Plotly.relayout(gd, 'sliders[1]', null); }) @@ -218,6 +219,7 @@ describe('update sliders interactions', function() { assertNodeCount('.' + constants.groupClassName, 0); expect(gd._fullLayout._pushmargin['slider-0']).toBeUndefined(); expect(gd._fullLayout._pushmargin['slider-1']).toBeUndefined(); + expect(gd.layout.sliders.length).toEqual(1); return Plotly.relayout(gd, { 'sliders[0].visible': true, diff --git a/test/jasmine/tests/transform_multi_test.js b/test/jasmine/tests/transform_multi_test.js index 73c264f50e9..283885186e3 100644 --- a/test/jasmine/tests/transform_multi_test.js +++ b/test/jasmine/tests/transform_multi_test.js @@ -580,3 +580,70 @@ describe('multiple traces with transforms:', function() { }); }); }); + +describe('restyle applied on transforms:', function() { + 'use strict'; + + afterEach(destroyGraphDiv); + + it('should be able', function(done) { + var gd = createGraphDiv(); + + var data = [{ y: [2, 1, 2] }]; + + var transform0 = { + type: 'filter', + target: 'y', + operation: '>', + value: 1 + }; + + var transform1 = { + type: 'groupby', + groups: ['a', 'b', 'b'] + }; + + Plotly.plot(gd, data).then(function() { + expect(gd.data.transforms).toBeUndefined(); + + return Plotly.restyle(gd, 'transforms[0]', transform0); + }) + .then(function() { + var msg = 'to generate blank transform objects'; + + expect(gd.data[0].transforms[0]).toBe(transform0, msg); + + // make sure transform actually works + expect(gd._fullData[0].y).toEqual([2, 2], msg); + + return Plotly.restyle(gd, 'transforms[1]', transform1); + }) + .then(function() { + var msg = 'to generate blank transform objects (2)'; + + expect(gd.data[0].transforms[0]).toBe(transform0, msg); + expect(gd.data[0].transforms[1]).toBe(transform1, msg); + expect(gd._fullData[0].y).toEqual([2], msg); + + return Plotly.restyle(gd, 'transforms[0]', null); + }) + .then(function() { + var msg = 'to remove transform objects'; + + expect(gd.data[0].transforms[0]).toBe(transform1, msg); + expect(gd.data[0].transforms[1]).toBeUndefined(msg); + expect(gd._fullData[0].y).toEqual([2], msg); + expect(gd._fullData[1].y).toEqual([1, 2], msg); + + return Plotly.restyle(gd, 'transforms', null); + }) + .then(function() { + var msg = 'to remove all transform objects'; + + expect(gd.data[0].transforms).toBeUndefined(msg); + expect(gd._fullData[0].y).toEqual([2, 1, 2], msg); + }) + .then(done); + }); + +}); diff --git a/test/jasmine/tests/updatemenus_test.js b/test/jasmine/tests/updatemenus_test.js index f3e1e95a06e..e8a736b6bbe 100644 --- a/test/jasmine/tests/updatemenus_test.js +++ b/test/jasmine/tests/updatemenus_test.js @@ -260,7 +260,8 @@ describe('update menus interactions', function() { expect(gd._fullLayout._pushmargin['updatemenu-1']).toBeDefined(); return Plotly.relayout(gd, 'updatemenus[1]', null); - }).then(function() { + }) + .then(function() { assertNodeCount('.' + constants.containerClassName, 0); expect(gd._fullLayout._pushmargin['updatemenu-0']).toBeUndefined(); expect(gd._fullLayout._pushmargin['updatemenu-1']).toBeUndefined(); @@ -269,7 +270,8 @@ describe('update menus interactions', function() { 'updatemenus[0].visible': true, 'updatemenus[1].visible': true }); - }).then(function() { + }) + .then(function() { assertMenus([0, 0]); expect(gd._fullLayout._pushmargin['updatemenu-0']).toBeDefined(); expect(gd._fullLayout._pushmargin['updatemenu-1']).toBeDefined(); @@ -278,7 +280,8 @@ describe('update menus interactions', function() { 'updatemenus[0].visible': false, 'updatemenus[1].visible': false }); - }).then(function() { + }) + .then(function() { assertNodeCount('.' + constants.containerClassName, 0); expect(gd._fullLayout._pushmargin['updatemenu-0']).toBeUndefined(); expect(gd._fullLayout._pushmargin['updatemenu-1']).toBeUndefined(); @@ -291,21 +294,35 @@ describe('update menus interactions', function() { }] } }); - }).then(function() { + }) + .then(function() { assertMenus([0]); expect(gd._fullLayout._pushmargin['updatemenu-0']).toBeUndefined(); expect(gd._fullLayout._pushmargin['updatemenu-1']).toBeUndefined(); expect(gd._fullLayout._pushmargin['updatemenu-2']).toBeDefined(); return Plotly.relayout(gd, 'updatemenus[0].visible', true); - }).then(function() { + }) + .then(function() { assertMenus([0, 0]); expect(gd._fullLayout._pushmargin['updatemenu-0']).toBeDefined(); expect(gd._fullLayout._pushmargin['updatemenu-1']).toBeUndefined(); expect(gd._fullLayout._pushmargin['updatemenu-2']).toBeDefined(); + expect(gd.layout.updatemenus.length).toEqual(3); - done(); - }); + return Plotly.relayout(gd, 'updatemenus[0]', null); + }) + .then(function() { + assertMenus([0]); + expect(gd.layout.updatemenus.length).toEqual(2); + + return Plotly.relayout(gd, 'updatemenus', null); + }) + .then(function() { + expect(gd.layout.updatemenus).toBeUndefined(); + + }) + .then(done); }); it('should drop/fold buttons when clicking on header', function(done) {