diff --git a/misc/revision_test_data.py b/misc/revision_test_data.py new file mode 100755 index 0000000000000000000000000000000000000000..0f41fa9c1b748be0cbc6c5b917dc6739d3c21d89 --- /dev/null +++ b/misc/revision_test_data.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Copyright 2020 IndiScale GmbH <info@indiscale.com> +Copyright 2020 Timm Fitschen <t.fitschen@indiscale.com> +""" + +import caosdb +import random +import os + +# data model +c = caosdb.execute_query("FIND Test*") +if len(c) > 0: + print(c) + delete = input("Delete these entities?\nType `yes`:") + if delete == "yes": + c.delete(); + else: + print("You typed `{}`".format(delete)) + print("[Canceled]") + exit(0) + + +print("inserting test data") + +upload_file = open("test.dat", "w") +upload_file.write("hello world\n") +upload_file.close() + +testdata = caosdb.Container() +testdata.extend([ + caosdb.File("TestFile", + path="test.dat", + file="test.dat"), + caosdb.Property("TestRevisionOf", datatype="TestObsolete"), + caosdb.RecordType("TestObsolete"), + caosdb.RecordType("TestRecordType"), + caosdb.Property("TestProperty", datatype=caosdb.TEXT), + caosdb.Record("TestRecord" + ).add_parent("TestRecordType" + ).add_property("TestProperty", "this is a test"), +]) + +testdata.insert() +os.remove("test.dat") diff --git a/src/core/js/caosdb.js b/src/core/js/caosdb.js index 98d4feae395f47a0eaecb420ed99f231f4fbf70b..648e8249a258a9f723325ec34c230e07a5900514 100644 --- a/src/core/js/caosdb.js +++ b/src/core/js/caosdb.js @@ -183,10 +183,12 @@ function getEntityDatatype(element) { * @return {string} the data type */ function getPropertyDatatype(element) { - var dt_elem = $(element).find(".caosdb-property-datatype"); + var dt_elem = findElementByConditions(element, + x => x.classList.contains("caosdb-property-datatype"), + x => x.classList.contains("caosdb-preview-container")); if(dt_elem.length == 1){ - return dt_elem.text(); + return $(dt_elem[0]).text(); } else if (dt_elem.length > 1){ throw new Error("The datatype of this property could not uniquely be determined."); } diff --git a/src/core/js/edit_mode.js b/src/core/js/edit_mode.js index 96ff91d2d9a2a6e153b56e8f6b66b6669f93a70b..b68d8ca4d9d5d309f3374c3717fe001806241173 100644 --- a/src/core/js/edit_mode.js +++ b/src/core/js/edit_mode.js @@ -151,8 +151,13 @@ var edit_mode = new function() { } } - this.property_drop_listener = function(e) { edit_mode._drop_listener.call(this, e, edit_mode.add_dropped_property); } - this.parent_drop_listener = function(e) { edit_mode._drop_listener.call(this, e, edit_mode.add_dropped_parent); } + this.property_drop_listener = function(e) { + edit_mode._drop_listener.call(this, e, edit_mode.add_dropped_property); + } + + this.parent_drop_listener = function(e) { + edit_mode._drop_listener.call(this, e, edit_mode.add_dropped_parent); + } this._drop_listener = function(e, add_cb) { e.preventDefault(); @@ -308,11 +313,6 @@ var edit_mode = new function() { } - this.update_entity_by_id = async function(ent_id) { - var ent_element = $("#" + ent_id)[0]; - return this.update_entity(ent_element); - } - /** * Insert entities. * @@ -426,9 +426,11 @@ var edit_mode = new function() { getEntityDescription(ent_element), getEntityUnit(ent_element), ); - return await update(xml); + return await edit_mode.update(xml); } + this.update = update; + this.add_edit_mode_button = function(target, toggle_function) { var edit_mode_li = $('<li><button class="navbar-btn btn btn-link caosdb-f-btn-toggle-edit-mode">Edit Mode</button></li>'); $(target).append(edit_mode_li); @@ -1239,6 +1241,8 @@ var edit_mode = new function() { edit_mode.unhighlight(); app.old = entity; app.entity = $(entity).clone(true)[0]; + // remove preview stuff + $(app.entity).find(".caosdb-preview-container").remove(); edit_mode.smooth_replace(app.old, app.entity); edit_mode.add_save_button(app.entity, () => app.update(app.entity)); diff --git a/src/core/js/ext_revisions.js b/src/core/js/ext_revisions.js new file mode 100644 index 0000000000000000000000000000000000000000..cf0da0c78a83b87c38ecfdce9e3d576328e2b04c --- /dev/null +++ b/src/core/js/ext_revisions.js @@ -0,0 +1,227 @@ +/* + * ** header v3.0 + * This file is a part of the CaosDB Project. + * + * Copyright (C) 2019 IndiScale GmbH (info@indiscale.com) + * Copyright (C) 2019 Timm Fitschen (t.fitschen@indiscale.com) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + * + * ** end header + */ + +'use strict'; + +/** + * The ext_revisions module extends the edit_mode update functionality. + * + * The edit_mode.update_entity function is overridden by this module with a + * proxy pattern. That means, that the original function is still called, but a + * proxy (or wrapper) function adds further functionality. + * + * The extended update function creates a back-up version of the updated entity + * and and adds a revisionOf property to the updated entity which references + * the back-up. The back-up entity loses all of its original parents and gets + * an "Obsolete" as only parent instead. + * + * Per default, the module assumes two Entities to be present in the + * database. A RecordType named "Obsolete" and a Property named + * "revisionOf". The initialization is aborted if these entities cannot be + * found and the module remains inactive. + * + * @module ext_revisions + * @version 0.1 + * + * @requires jQuery + * @requires log + * @requires edit_mode + * @requires getEntityID + * @requires transaction + * @requires _createDocument + */ +var ext_revisions = function ($, logger, edit_mode, getEntityID, transaction, _createDocument) { + + + /** + * Default names for the two entities which are required by this module. + * + * TODO: rename + */ + var _datamodel = { obsolete: "Obsolete", revisionOf: "revisionOf" }; + + /** + * Generate and insert the back-up entity which stores the old state of the + * entity which is to be updated. + * + * The obsolete entity has only one parent named "Obsolete". + * Apart from that, the obsolete entity has all the properties, name, + * description and so on from the original entity before the update. + * + * @param {string} id - the id of the entity which is to be updated. + * @returns {string} the id of the newly created obsolete entity. + */ + var _insert_obsolete = async function (id) { + logger.debug("insert obsolete", id); + + // create new obsolete entity from the original + var obsolete = await transaction.retrieveEntityById(id); + $(obsolete).attr("id", "-1"); + $(obsolete).find("Permissions").remove(); + $(obsolete).find("Parent").remove(); + $(obsolete).append(`<Parent name="${_datamodel.obsolete}"/>`); + + var doc = _createDocument("Request"); + doc.firstElementChild.appendChild(obsolete); + var result = await transaction.insertEntitiesXml(doc); + return $(result.firstElementChild).find("[id]").first().attr("id"); + }; + + /** + * Generate a HTML string which represents a new "revisionOf" property + * which references the newly created obsolete entity. The property is + * meant to be appended to the property section of the entity which is to + * be updated. + * + * @param {string} obsolete_id - the id of the newly created obsolete + * entity. + * @returns {string} A HTML represesentation of an entity property. + */ + var _make_revision_of_property = async function (obsolete_id) { + logger.debug("_make_revision_of_property", obsolete_id); + var ret = (await transformation.transformProperty(str2xml(`<Response><Property id="${_datamodel._revisionOfId}" name="${_datamodel.revisionOf}" datatype="${_datamodel.obsolete}"></Property></Response>`))).firstElementChild; + + $(ret).append(`<div class="caosdb-property-edit-value"><select><option value="${obsolete_id}" selected="selected"></option></select></div>`); + + return ret; + } + + /** + * Remove all properties from ent_element with the id of the "revisionOf" + * property. + * + * @param {HTMLElement} ent_element - entity in HTML representation. + */ + var _remove_old_revision_of_property = function (ent_element) { + $(ent_element) + .find(".caosdb-f-entity-property") + .filter(function(index, property) { + if(_datamodel._revisionOfId === $(property) + .find(".caosdb-property-id").text()) { + return true; + } + return false; + }).remove(); + } + + /** + * Main functionality of this module is in here. + * + * This method is called by the (overridden) edit_mode.update_entity + * function before the actual update. It inserts a new obsolete entity + * which represesents the old state of the entity, deletes any revisionOf + * properties of the entity (if present) and adds a new revisionOf property + * which references the (newly inserted) obsolete entity. + * + * @param {HTMLElement} ent_element - The entity form which has been + * generated by the edit_mode with the changes by the user. + */ + var _create_revision = async function (ent_element) { + logger.debug("create revision", ent_element); + var id = getEntityID(ent_element); + + var obsolete_id = await _insert_obsolete(id); + + // remove old revision of and add new one + _remove_old_revision_of_property(ent_element); + var revision_of_property = await _make_revision_of_property(obsolete_id); + var properties_section = ent_element.getElementsByClassName("caosdb-properties")[0]; + properties_section.appendChild(revision_of_property); + }; + + /** + * Test whether the necessary entities exist ("revisionOf" and "Obsolete"). + */ + var _check_datamodel = async function() { + var results = Promise.all([ + query(`FIND RecordType ${_datamodel.obsolete}`), + query(`FIND Property ${_datamodel.revisionOf}`) + ]); + + for (let result of (await results)) { + if (result.length !== 1) { + throw new Error("Invalid datamodel"); + } + + var name = getEntityName(result[0]); + if (name && name.toLowerCase() === _datamodel.revisionOf.toLowerCase()) { + _datamodel._revisionOfId = getEntityID(result[0]); + _datamodel.revisionOf = name; + } else if (name && name.toLowerCase() === _datamodel.obsolete.toLowerCase()) { + _datamodel.obsolete = name; + } + } + }; + + /** + * Initialize the ext_revisions module. + * + * Per default, the module assumes two Entities to be present in the + * database. A RecordType named "Obsolete" and a Property named + * "revisionOf". The initialization is aborted if these entities cannot be + * found and the module remains inactive. For testing purposes the names of + * these entities can be set to different values via the respective + * parameters. + * + * @param {string} [obsolete] - The name of the obsolete RecordType. + * @param {string} [revisionOf] - The name of the revisionOf Property. + */ + var init = async function (obsolete, revisionOf) { + if (typeof obsolete === "string") { + _datamodel.obsolete = obsolete; + } + if (typeof revisionOf === "string") { + _datamodel.revisionOf = revisionOf; + } + + try { + await _check_datamodel(); + } catch (err) { + logger.error("could not init ext_revisions", err); + return; + } + + (function(proxied) { + edit_mode.update_entity = async function(ent_element) { + await _create_revision(ent_element); + return await proxied.apply(this, arguments) + }; + })(edit_mode.update_entity); + } + + return { + // public members, part of the API + init: init, + // private members, exposed for testing + _make_revision_of_property: _make_revision_of_property, + _datamodel: _datamodel, + } +}($, log.getLogger("ext_revisions"), edit_mode, getEntityID, transaction, _createDocument); + + +// this will be replaced by require.js in the future. +$(document).ready(function () { + if ("${BUILD_MODULE_EXT_REVISIONS}" == "ENABLED") { + caosdb_modules.register(ext_revisions); + } +}); diff --git a/src/core/xsl/main.xsl b/src/core/xsl/main.xsl index dc4cc0e69add1cff3210677e53a4f513dca710c1..f4a128f63f52488657b32e8ff03043c276d4aaa3 100644 --- a/src/core/xsl/main.xsl +++ b/src/core/xsl/main.xsl @@ -235,6 +235,11 @@ <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/ext_bottom_line.js')"/> </xsl:attribute> </xsl:element> + <xsl:element name="script"> + <xsl:attribute name="src"> + <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/ext_revisions.js')"/> + </xsl:attribute> + </xsl:element> <!--JS_EXTENSIONS--> </xsl:template> <xsl:template name="caosdb-data-container"> diff --git a/test/core/index.html b/test/core/index.html index b2ec4fceac7f4a78c621046647e55225c1cfb11b..0d97a415334fd441319eec7e4db262c34d64ef07 100644 --- a/test/core/index.html +++ b/test/core/index.html @@ -65,6 +65,7 @@ <script src="js/proj4leaflet.js"></script> <script src="js/ext_map.js"></script> <script src="js/ext_bottom_line.js"></script> + <script src="js/ext_revisions.js"></script> <script src="js/autocomplete.js"></script> <!--EXTENSIONS--> <script src="js/modules/webcaosdb.js.js"></script> @@ -82,6 +83,7 @@ <script src="js/modules/ext_references.js.js"></script> <script src="js/modules/ext_map.js.js"></script> <script src="js/modules/ext_bottom_line.js.js"></script> + <script src="js/modules/ext_revisions.js.js"></script> <script src="js/modules/autocomplete.js.js"></script> </body> </html> diff --git a/test/core/js/modules/edit_mode.js.js b/test/core/js/modules/edit_mode.js.js index 61058a4301cc43c6f6da56999bcfccd320d92424..c6326e4400750f75ef0b939990a941c2f706da2e 100644 --- a/test/core/js/modules/edit_mode.js.js +++ b/test/core/js/modules/edit_mode.js.js @@ -169,10 +169,6 @@ QUnit.test("add_property_trash_button", function(assert){ assert.ok(edit_mode.add_property_trash_button); }); -QUnit.test("update_entity_by_id", function(assert){ - assert.ok(edit_mode.update_entity_by_id); -}); - QUnit.test("insert_entity", function(assert){ assert.ok(edit_mode.insert_entity); }); diff --git a/test/core/js/modules/ext_revisions.js.js b/test/core/js/modules/ext_revisions.js.js new file mode 100644 index 0000000000000000000000000000000000000000..bd9787afec3039984e3b067638c9ae6a9a3fd422 --- /dev/null +++ b/test/core/js/modules/ext_revisions.js.js @@ -0,0 +1,118 @@ +/* + * ** header v3.0 + * This file is a part of the CaosDB Project. + * + * Copyright (C) 2019 IndiScale GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + * + * ** end header + */ + +'use strict'; + +var ext_revisions_test_suite = function ($, ext_revisions, QUnit, edit_mode) { + + var datamodel = ext_revisions._datamodel; + + QUnit.module("ext_revisions.js", { + before: function (assert) { + // setup before module + this.original_update_entity = edit_mode.update_entity; + this.original_insert = transaction.insertEntitiesXml; + this.original_retrieve = transaction.retrieveEntityById; + this.original_query = query; + }, + beforeEach: function (assert) { + // setup before each test + datamodel.obsolete = "UNITTESTObsolete"; + datamodel.revisionOf = "UNITTESTRevisionOf"; + }, + afterEach: function (assert) { + // teardown after each test + query = this.original_query; + edit_mode.update_entity = this.original_update_entity; + transaction.insertEntitiesXml = this.original_insert; + transaction.retrieveEntityById = this.original_retrieve; + }, + after: function (assert) { + // teardown after module + } + }); + + QUnit.test("_make_revision_of_property", async function(assert) { + var p = await ext_revisions._make_revision_of_property("1234"); + var editfield = $(p).find(".caosdb-property-edit-value"); + var value = $(editfield).find("select").first()[0].selectedOptions[0].value; + assert.ok($(p).hasClass("caosdb-f-entity-property"), "is property"); + assert.equal(value, "1234", "has value 1234"); + assert.equal(getPropertyName(p), datamodel.revisionOf, "has revisionOf name"); + assert.equal(getPropertyDatatype(p), datamodel.obsolete, "has Obsolete datatype"); + }); + + /** + * This is a rather complete test, not a unit test. + */ + QUnit.test("update calls update_entity through proxy", async function (assert) { + var done = assert.async(3); + var done_query = assert.async(2); + var ent_element = $('<div data-entity-id="15"><div class="caosdb-properties"/></div>')[0]; + + // mock server responses to several requests... + var retrieve_fun = async function(id) { + assert.equal(id, "15", "retrieve id 15"); + done(); + return $(`<Record id="15"><Parent name="ORIG_PARENT"/></Record>`)[0]; + } + var insert_fun = async function(xml) { + var rec = xml.firstElementChild.firstElementChild; + assert.equal(rec.id, "-1", "insert with tmp id"); + assert.equal($(rec).find("Parent").attr("name"), datamodel.obsolete, "Obsolete Parent"); + xml.firstElementChild.firstElementChild.id = "2345"; + done(); + return xml; + }; + var update_fun = async function(ent_element) { + var prop = edit_mode.getProperties(ent_element)[0]; + assert.equal(prop.name, datamodel.revisionOf, "has revisionOf"); + assert.equal(prop.value, "2345", "revisionOf 2345"); + done(); + }; + var query_fun = async function(query) { + assert.ok(query.startsWith("FIND") && ( query.endsWith(datamodel.obsolete) || query.endsWith(datamodel.revisionOf)), query); + done_query(); // called twice + return [$(`<div data-entity-name="${datamodel.revisionOf}" data-caosdb-id="3456"/>`)[0]]; + } + + // injecting the server mock-up responses. + transaction.retrieveEntityById = retrieve_fun; + transaction.insertEntitiesXml = insert_fun; + edit_mode.update_entity = update_fun; + query = query_fun; + + + // actual tests + assert.equal(update_fun, edit_mode.update_entity, "before init, the edit_mode.update_entity function has not been overridden."); + + // call init which checks the datamodel and overwrites the + // edit_mode.update_entity function. + await ext_revisions.init(); + assert.notEqual(update_fun, edit_mode.update_entity, "after init, the edit_mode.update_entity hab been overriden with a proxy calling the update_fun and the original function."); + + // call edit_mode.update_entity which calls the insert_fun and the + // update_fun + await edit_mode.update_entity(ent_element); + }); + +}($, ext_revisions, QUnit, edit_mode);