diff --git a/package.json b/package.json index 19bda3093..940ef1690 100644 --- a/package.json +++ b/package.json @@ -25,13 +25,15 @@ "jsonlint": "josdejong/jsonlint#85a19d7" }, "devDependencies": { + "chai": "^3.0.0", + "chai-spies": "^0.6.0", "gulp": "^3.8.11", "gulp-concat-css": "^2.0.0", "gulp-minify-css": "^0.4.5", "gulp-shell": "^0.3.0", "gulp-util": "^3.0.3", "mkdirp": "^0.5.0", - "mocha": "^2.1.0", + "mocha": "^2.2.5", "uglify-js": "^2.4.16", "webpack": "^1.5.3" } diff --git a/src/js/History.js b/src/js/History.js index d195a368e..6ebddcd7f 100644 --- a/src/js/History.js +++ b/src/js/History.js @@ -170,6 +170,7 @@ History.prototype.canRedo = function () { /** * Undo the last action + * @returns applied history entry see {@link #add} */ History.prototype.undo = function () { if (this.canUndo()) { @@ -191,10 +192,12 @@ History.prototype.undo = function () { // fire onchange event this.onChange(); } + return obj || null; }; /** * Redo the last action + * @returns applied history entry see {@link #add} */ History.prototype.redo = function () { if (this.canRedo()) { @@ -217,6 +220,7 @@ History.prototype.redo = function () { // fire onchange event this.onChange(); } + return obj || null; }; module.exports = History; diff --git a/src/js/textmode.js b/src/js/textmode.js index f505dca5b..3d006b01a 100644 --- a/src/js/textmode.js +++ b/src/js/textmode.js @@ -154,7 +154,7 @@ textmode.create = function (container, options) { if (options.change) { // register onchange event editor.on('change', function () { - options.change(); + options.change(replaceRootJSONPatch(me)); }); } } @@ -170,13 +170,13 @@ textmode.create = function (container, options) { // register onchange event if (this.textarea.oninput === null) { this.textarea.oninput = function () { - options.change(); + options.change(replaceRootJSONPatch(me)); } } else { // oninput is undefined. For IE8- this.textarea.onchange = function () { - options.change(); + options.change(replaceRootJSONPatch(me)); } } } @@ -337,6 +337,14 @@ textmode.setText = function(jsonText) { } }; +function replaceRootJSONPatch(textmode){ + return { + op: "replace", + path: "", + value: textmode.get() + }; +} + // define modes module.exports = [ { diff --git a/src/js/treemode.js b/src/js/treemode.js index 353d48bee..b865594c9 100644 --- a/src/js/treemode.js +++ b/src/js/treemode.js @@ -301,7 +301,7 @@ treemode._onAction = function (action, params) { // trigger the onChange callback if (this.options.change) { try { - this.options.change(); + this.options.change(translateChangeToJSONPatch(action, params)); } catch (err) { util.log('Error in change callback: ', err); @@ -580,11 +580,13 @@ treemode._createFrame = function () { treemode._onUndo = function () { if (this.history) { // undo last action - this.history.undo(); + var historyEntry = this.history.undo(); - // trigger change callback - if (this.options.change) { - this.options.change(); + // trigger change callback if anything have changed + if (this.options.change && historyEntry) { + this.options.change( + translateChangeToJSONPatch(historyEntry.action, historyEntry.params) + ); } } }; @@ -596,11 +598,12 @@ treemode._onUndo = function () { treemode._onRedo = function () { if (this.history) { // redo last action - this.history.redo(); - - // trigger change callback - if (this.options.change) { - this.options.change(); + var historyEntry = this.history.redo(); + // trigger change callback if anything have changed + if (this.options.change && historyEntry) { + this.options.change( + translateChangeToJSONPatch(historyEntry.action, historyEntry.params) + ); } } }; @@ -724,6 +727,109 @@ treemode._createTable = function () { this.frame.appendChild(contentOuter); }; +/** + * Translate our internal change info into JSON-Patch format + * @see http://tools.ietf.org/html/rfc6902 + * @param {String} action JSONEditor action + * @param {Object} params JSONEditor params + * @return {Object} single JSON-Patch entry + */ +function translateChangeToJSONPatch(action, params){ + /** + * Get path to node in JSON Pointer format + * (http://tools.ietf.org/html/rfc6901) + * _Almost_ like params.node.path().join("/") + * @param {Node} node jsoneditor node in question + * @returns {String} path + */ + function JSONPointer(node){ + var path = ""; + while (node.parent) { + var field = node.field != undefined ? node.field : node.index; + switch(typeof field){ + case "string": + path = "/" + escapePathComponent(field) + path; + break; + case "number": + path = "/" + field + path; + break; + } + node = node.parent; + } + return path; + + } + /** + * Escape `/` and `~`, according to JSON-Pointer rules. + * @param {String} str string to escape + * @returns {String} escaped string + */ + function escapePathComponent(str) { + if (str.indexOf('/') === -1 && str.indexOf('~') === -1) + return str; + return str.replace(/~/g, '~0').replace(/\//g, '~1'); + } + var patch; + switch(action){ + case "duplicateNode": + console.warn("duplicateNode->copy Is not supported yet, as currently new node with same name is created, what violates JSON-Patch"); + break; + case "changeType": + (params.node.type == "array" || params.node.type == "object") && console.warn("changeType->replace may behave strange, as even if new node is created with specified type, its `node.value==\"\"`") + patch = { + op: "replace", + path: JSONPointer(params.node), + value: params.node.value + } + break; + case "editValue": + patch = { + op: "replace", + path: JSONPointer(params.node), + value: params.newValue + } + break; + case "removeNode": + patch = { + op: "remove", + path: JSONPointer(params.node) + } + break; + case "insertAfterNode": + case "insertBeforeNode": + case "appendNode": + console.warn("insertBeforeNode,appendNode->add may behave strange, as even if new node is created with specified type, its `node.value==\"\"`", + "also when inserting item into an array, new path is given, and there is no info about previous indexes, so it's hard to distinguish whether to use `-`, old index, or if it is not an array item, so we should stick to the given path"); + patch = { + op: "add", + path: JSONPointer(params.node), + value: params.node.value + } + break; + case "moveNode": + console.warn("moveNode->move Still does not cover moving array items, as their name was already changed to `\"\"` which is fully valid object key, so we cannot distinguish it"); + if(params.startParent !== params.endParent){ + patch = { + op: "move", + from: JSONPointer(params.startParent) + "/" + params.node.field, + path: JSONPointer(params.node) + } + } + break; + case "editField": + patch = { + op: "move", + from: JSONPointer(params.node.parent) + "/" + params.oldValue, + path: JSONPointer(params.node) + } + break; + case "sort": + // no changes to the JSON itself + break; + } + return patch; +} + // define modes module.exports = [ { @@ -741,4 +847,4 @@ module.exports = [ mixin: treemode, data: 'json' } -]; \ No newline at end of file +]; diff --git a/test/change.JSONPatch.test.js b/test/change.JSONPatch.test.js new file mode 100644 index 000000000..1d0e54e26 --- /dev/null +++ b/test/change.JSONPatch.test.js @@ -0,0 +1,238 @@ +// var assert = require('assert'); +// var util = require('../src/js/util'); + +// var chai = require('chai') +// , spies = require('chai-spies'); +// chai.use(spies); + +var assert = chai.assert; +var expect = chai.expect; + +describe('@change', function () { + + context('should return JSON Patch', function(){ + var container, editor, json, changeSpy; + beforeEach(function(){ + container = document.createElement("div"); + document.body.appendChild(container); + changeSpy = chai.spy(); + editor = new JSONEditor(container,{ + change: changeSpy + }); + json = { + "Array": [1, 2, 3], + "Boolean": true, + "Null": null, + "Number": 123, + "Object": {"a": "b", "c": "d"}, + "String": "Hello World" + }; + editor.set(json); + }); + afterEach(function(){ + container && container.parentElement && container.parentElement.removeChild(container); + }) + + context('when changes value', function () { + it('from Boolean, replace', function () { + var valueField = container.querySelectorAll("[contenteditable=true][class=value]")[0]; + valueField.textContent = "false"; + valueField.dispatchEvent(new Event("blur")); + expect(changeSpy).to.have.been.called(); + expect(changeSpy).to.have.been.called.once; + expect(changeSpy).to.have.been.called.with({op: "replace",path: "/Boolean",value: false}); + }); + + it('from null, replace', function () { + var valueField = container.querySelectorAll("[contenteditable=true][class=value]")[1]; + valueField.textContent = "notNull"; + valueField.dispatchEvent(new Event("blur")); + expect(changeSpy).to.have.been.called(); + expect(changeSpy).to.have.been.called.once; + expect(changeSpy).to.have.been.called.with({op: "replace",path: "/Null",value: "notNull"}); + }); + + it('from Number, replace', function () { + var valueField = container.querySelectorAll("[contenteditable=true][class=value]")[2]; + valueField.textContent = "1234"; + valueField.dispatchEvent(new Event("blur")); + expect(changeSpy).to.have.been.called(); + expect(changeSpy).to.have.been.called.once; + expect(changeSpy).to.have.been.called.with({op: "replace",path: "/Number",value: 1234}); + }); + + it('from String, replace', function () { + var valueField = container.querySelectorAll("[contenteditable=true][class=value]")[3]; + valueField.textContent = "Hello JSON Patch"; + valueField.dispatchEvent(new Event("blur")); + expect(changeSpy).to.have.been.called(); + expect(changeSpy).to.have.been.called.once; + expect(changeSpy).to.have.been.called.with({op: "replace",path: "/String",value: "Hello JSON Patch"}); + }); + + }); + + context('when changes type', function () { + + context('from Array', function () { + it('to String, replace', function () { + editor.node.childs[0]._onChangeType("string"); + + expect(changeSpy).to.have.been.called(); + expect(changeSpy).to.have.been.called.once; + expect(changeSpy).to.have.been.called.with({op: "replace",path: "/Array",value: ""}); + }); + it('to Object, replace (pending)', function () { + editor.node.childs[0]._onChangeType("object"); + + expect(changeSpy).to.have.been.called(); + expect(changeSpy).to.have.been.called.once; + expect(changeSpy).to.have.been.called.with({op: "replace",path: "/Array",value: {"":3}}); + }); + + }); // EO Array + + context('from Boolean', function () { + it('to String, replace', function () { + editor.node.childs[1]._onChangeType("string"); + + expect(changeSpy).to.have.been.called(); + expect(changeSpy).to.have.been.called.once; + expect(changeSpy).to.have.been.called.with({op: "replace",path: "/Boolean",value: "true"}); + }); + it('to Object, replace (pending)', function () { + editor.node.childs[1]._onChangeType("object"); + + expect(changeSpy).to.have.been.called(); + expect(changeSpy).to.have.been.called.once; + expect(changeSpy).to.have.been.called.with({op: "replace",path: "/Boolean",value: {}}); + }); + it('to Array, replace (pending)', function () { + editor.node.childs[1]._onChangeType("array"); + + expect(changeSpy).to.have.been.called(); + expect(changeSpy).to.have.been.called.once; + expect(changeSpy).to.have.been.called.with({op: "replace",path: "/Boolean",value: []}); + }); + + }); // EO Boolean + + context('from Null', function () { + it('to String, replace', function () { + editor.node.childs[2]._onChangeType("string"); + + expect(changeSpy).to.have.been.called(); + expect(changeSpy).to.have.been.called.once; + expect(changeSpy).to.have.been.called.with({op: "replace",path: "/Null",value: "null"}); + }); + it('to Object, replace (pending)', function () { + editor.node.childs[2]._onChangeType("object"); + + expect(changeSpy).to.have.been.called(); + expect(changeSpy).to.have.been.called.once; + expect(changeSpy).to.have.been.called.with({op: "replace",path: "/Null",value: {}}); + }); + it('to Array, replace (pending)', function () { + editor.node.childs[2]._onChangeType("array"); + + expect(changeSpy).to.have.been.called(); + expect(changeSpy).to.have.been.called.once; + expect(changeSpy).to.have.been.called.with({op: "replace",path: "/Null",value: []}); + }); + + }); // EO Null + + context('from Number', function () { + it('to String, replace', function () { + editor.node.childs[3]._onChangeType("string"); + + expect(changeSpy).to.have.been.called(); + expect(changeSpy).to.have.been.called.once; + expect(changeSpy).to.have.been.called.with({op: "replace",path: "/Number",value: "123"}); + }); + it('to Object, replace (pending)', function () { + editor.node.childs[3]._onChangeType("object"); + + expect(changeSpy).to.have.been.called(); + expect(changeSpy).to.have.been.called.once; + expect(changeSpy).to.have.been.called.with({op: "replace",path: "/Number",value: {}}); + }); + it('to Array, replace (pending)', function () { + editor.node.childs[3]._onChangeType("array"); + + expect(changeSpy).to.have.been.called(); + expect(changeSpy).to.have.been.called.once; + expect(changeSpy).to.have.been.called.with({op: "replace",path: "/Number",value: []}); + }); + + }); // EO Number + + context('from Object', function () { + it('to String, replace', function () { + editor.node.childs[4]._onChangeType("string"); + + expect(changeSpy).to.have.been.called(); + expect(changeSpy).to.have.been.called.once; + expect(changeSpy).to.have.been.called.with({op: "replace",path: "/Object",value: ""}); + }); + it('to Array, replace (pending)', function () { + editor.node.childs[4]._onChangeType("array"); + + expect(changeSpy).to.have.been.called(); + expect(changeSpy).to.have.been.called.once; + expect(changeSpy).to.have.been.called.with({op: "replace",path: "/Object",value: ["b","d"]}); + }); + + }); // EO Object + + context('from String', function () { + it('to Object, replace (pending)', function () { + editor.node.childs[5]._onChangeType("object"); + + expect(changeSpy).to.have.been.called(); + expect(changeSpy).to.have.been.called.once; + expect(changeSpy).to.have.been.called.with({op: "replace",path: "/String",value: {}}); + }); + it('to Array, replace (pending)', function () { + editor.node.childs[5]._onChangeType("array"); + + expect(changeSpy).to.have.been.called(); + expect(changeSpy).to.have.been.called.once; + expect(changeSpy).to.have.been.called.with({op: "replace",path: "/String",value: []}); + }); + + }); // EO String + });// EO change type + + context('when removes a node', function () { + + it('from Array, remove', function () { + editor.node.childs[0].childs[1]._onRemove(); + + expect(changeSpy).to.have.been.called(); + expect(changeSpy).to.have.been.called.once; + expect(changeSpy).to.have.been.called.with({op: "remove",path: "/Array/1"}); + }); + it('from Object, remove', function () { + editor.node.childs[1]._onRemove(); + + expect(changeSpy).to.have.been.called(); + expect(changeSpy).to.have.been.called.once; + expect(changeSpy).to.have.been.called.with({op: "remove",path: "/Boolean"}); + }); + });// EO remove node + + context('when edits a node\'s key', function () { + + it('move', function () { + var keyField = container.querySelectorAll("[contenteditable=true][class=field]")[0]; + keyField.innerText = "newArray"; + keyField.dispatchEvent(new Event("blur")); + + expect(changeSpy).to.have.been.called(); + expect(changeSpy).to.have.been.called.once; + expect(changeSpy).to.have.been.called.with({op: "move", from: "/Array", path: "/newArray"}); + }); + });// EO edit + }); +}); diff --git a/test/index.html b/test/index.html new file mode 100644 index 000000000..3e9e4ccfe --- /dev/null +++ b/test/index.html @@ -0,0 +1,24 @@ + + +
+