diff --git a/CHANGELOG.md b/CHANGELOG.md index ae5c058e15f82a98df92e6822efe174fe54edece..085bef71c307588c0a2826685d9b2da88549c4ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,20 +4,71 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added (for new features, dependecies etc.) + +* Documentation link in standard footer +* Build properties for footer elements: + * `BUILD_FOOTER_CONTACT_HREF` + * `BUILD_FOOTER_IMPRINT_HREF` + * `BUILD_FOOTER_DOCS_HREF` + * `BUILD_FOOTER_SOURCES_HREF` + * `BUILD_FOOTER_LICENCE_HREF` + See `build.properties.d/00_default.properties` for more information +* Start editing an entity/creating a new record directly by adding an `#edit` or + `#new_record` URI fragment, respectively. +* Optional WYSIWYG editor with markdown output for text properties. Controled by + the `BUILD_MODULE_EXT_EDITMODE_WYSIWYG_TEXT` build variable which is set do + `DISABLED` by default. + - Added button to version history panel that allows restoring old versions + +### Changed (for changes in existing functionality) + +* Default footer elements contain invalid links for imprint, contact, and data-policy now + +### Deprecated (for soon-to-be removed features) + +### Removed (for now removed features) + +* Build property `BUILD_CUSTOM_IMPRINT`. Please use BUILD_FOOTER_IMPRINT_HREF + and link a document instead. You can always put a html page to + `src/ext/html/` and link to that. + +### Fixed (for any bug fixes) +- #156 +- #251 + +### Security (in case of vulnerabilities) + +### Documentation (for notable additions or changes of the documentation) + + ## [0.4.1] - 2021-11-04 ### Added (for new features, dependecies etc.) * `form_panel` module for conveniently creating a panel for web forms. +* `restore_old_version` function to base functionality (caosdb.js) +* buttons to the version history modal that allow restoring older versions ### Changed (for changes in existing functionality) +* Default footer elements contain invalid links for imprint, contact, and data-policy now + ### Deprecated (for soon-to-be removed features) ### Removed (for now removed features) +* Build property `BUILD_CUSTOM_IMPRINT`. Please use BUILD_FOOTER_IMPRINT_HREF + and link a document instead. You can always put a html page to + `src/ext/html/` and link to that. + ### Fixed (for any bug fixes) +* Auto-completion and edit_mode can handle entity names with empty spaces better now +* [#251](https://gitlab.indiscale.com/caosdb/src/caosdb-webui/-/issues/251) - Data loss when editing Entities with URL-like properties + ### Security (in case of vulnerabilities) ### Documentation (for notable additions or changes of the documentation) diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index c6043d09d049b91cfc0a03560970537feb8bbc06..f3e1d1706dd8dd9bdbdab16377c2c75ed0299f7f 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -24,5 +24,12 @@ * proj4js-2.5.0 * Proj4Leaflet-1.0.1 +## For CKEditor (WYSIWYG editor in edit mode) + +* we're using a custom-built ckeditor 31.0.0 from + https://ckeditor.com/ckeditor-5/online-builder/ with a customized set of + editor plugins. Please refer to the `package.json` within + `libs/ckeditor...zip` for a full list of said plugins. + ## For testing * qunit-2.9.2 diff --git a/Makefile b/Makefile index 4c64a38d01f0273059e29f6dac0448967b005b86..5cca686319c617006007e4884cfb269796ac7af4 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ LIBS_DIR = $(abspath libs) TEST_CORE_DIR = $(abspath test/core/) TEST_EXT_DIR = $(abspath test/ext) TEST_SSS_DIR =$(abspath test/server_side_scripting) -LIBS = fonts css/fonts css/bootstrap.css css/bootstrap-icons.css js/state-machine.js js/jquery.js js/showdown.js js/dropzone.js css/dropzone.css js/loglevel.js js/leaflet.js css/leaflet.css css/images js/leaflet-latlng-graticule.js js/proj4.js js/proj4leaflet.js js/leaflet-coordinates.js css/leaflet-coordinates.css js/leaflet-graticule.js js/bootstrap-select.js css/bootstrap-select.css js/bootstrap-autocomplete.min.js js/plotly.js js/pako.js js/utif.js js/bootstrap.js js/qrcode.js +LIBS = fonts css/fonts css/bootstrap.css css/bootstrap-icons.css js/state-machine.js js/jquery.js js/showdown.js js/dropzone.js css/dropzone.css js/loglevel.js js/leaflet.js css/leaflet.css css/images js/leaflet-latlng-graticule.js js/proj4.js js/proj4leaflet.js js/leaflet-coordinates.js css/leaflet-coordinates.css js/leaflet-graticule.js js/bootstrap-select.js css/bootstrap-select.css js/bootstrap-autocomplete.min.js js/plotly.js js/pako.js js/utif.js js/bootstrap.js js/qrcode.js js/ckeditor.js TEST_LIBS = $(LIBS) js/qunit.js css/qunit.css $(subst $(TEST_CORE_DIR)/,,$(shell find $(TEST_CORE_DIR)/)) @@ -73,7 +73,7 @@ merge_js: JS_DIST_BUNDLE=$${JS_DIST_BUNDLE} AUTO_DISCOVER_MODULES=$$AUTO_DISCOVER_MODULES misc/merge_js.sh $${MODULE_DEPENDENCIES[*]} EXCLUDE_EXPR = %~ %.backup -BUILDFILELIST = $(filter-out $(EXCLUDE_EXPR),$(wildcard build.properties.d/*)) +BUILDFILELIST = $(sort $(filter-out $(EXCLUDE_EXPR),$(wildcard build.properties.d/*))) build_properties: @set -a -e ; \ pushd build.properties.files ; \ @@ -176,6 +176,9 @@ cp-ext: for f in $(wildcard $(SRC_EXT_DIR)/xsl/*) ; do \ echo "y" | $(CMD_COPY_EXT_FILES) "$$(realpath "$$f")" $(PUBLIC_DIR)/xsl/ ; \ done + for f in $(wildcard $(SRC_EXT_DIR)/include/*) ; do \ + echo "y" | $(CMD_COPY_EXT_FILES) "$$(realpath "$$f")" "$(PUBLIC_DIR)/$$(basename "$$f")" ; \ + done cp-ext-test: for f in $(wildcard $(TEST_EXT_DIR)/js/*) ; do \ @@ -302,6 +305,9 @@ $(LIBS_DIR)/js/utif.js: unzip $(LIBS_DIR)/js $(LIBS_DIR)/js/qrcode.js: unzip $(LIBS_DIR)/js ln -s $(LIBS_DIR)/qrcode-1.4.4/qrcode.min.js $@ +$(LIBS_DIR)/js/ckeditor.js: unzip $(LIBS_DIR)/js + ln -s $(LIBS_DIR)/ckeditor5-31.0.0-k356w86hp13l/build/ckeditor.js $@ + $(addprefix $(LIBS_DIR)/, js css): mkdir $@ || true diff --git a/build.properties.d/00_default.properties b/build.properties.d/00_default.properties index df68f1012cd71b49538b073a4c9d17e85b2214e4..535a6c846a5c39c48937dee43f087501f43285ae 100644 --- a/build.properties.d/00_default.properties +++ b/build.properties.d/00_default.properties @@ -60,6 +60,8 @@ BUILD_MODULE_USER_MANAGEMENT_CHANGE_OWN_PASSWORD_REALM=CaosDB BUILD_MODULE_EXT_RESOLVE_REFERENCES=ENABLED BUILD_EXT_REFERENCES_CUSTOM_RESOLVER=person_reference +BUILD_MODULE_EXT_EDITMODE_WYSIWYG_TEXT=DISABLED + ############################################################################## # Navbar properties ############################################################################## @@ -77,7 +79,24 @@ BUILD_FAVICON=pics/caosdb_logo_42.png ############################################################################## # Link to the data policy statement document. -BUILD_FOOTER_DATA_POLICY_HREF=https://missing-domain.com/missing-page +BUILD_FOOTER_DATA_POLICY_HREF="Please configure me!" + +# Contact mail or link to contact information for the responsible administrator of this caosdb server instance. +BUILD_FOOTER_CONTACT_HREF="Please configure me!" +#BUILD_FOOTER_CONTACT_HREF=mailto:info@indiscale.com + +# Link to imprint for this caosdb server instance. +BUILD_FOOTER_IMPRINT_HREF="Please configure me!" +#BUILD_FOOTER_IMPRINT_HREF=https://www.indiscale.com/imprint/ + +# Link to docs (should rarely be changed (maybe for additional docs)) +BUILD_FOOTER_DOCS_HREF="https://docs.indiscale.com" + +# Link to sources (should almost never be changed (maybe for additional sources)) +BUILD_FOOTER_SOURCES_HREF="https://gitlab.com/caosdb" + +# Link to license (should almost never be changed (maybe for other languages)) +BUILD_FOOTER_LICENCE_HREF="https://www.gnu.org/licenses/agpl-3.0.en.html" # Custom footer elements can be placed here (will be placed inside a <div> # element). @@ -90,7 +109,6 @@ BUILD_FOOTER_CUSTOM_ELEMENT_ONE= # ${BUILD_NUMBER}. BUILD_FOOTER_CUSTOM_ELEMENT_TWO= # BUILD_FOOTER_CUSTOM_ELEMENT_TWO=$(cat footer_element_2.html) -BUILD_CUSTOM_IMPRINT='<p> Put an imprint note here </p>' ############################################################################## # ext_trigger_crawler_form properties @@ -157,4 +175,6 @@ MODULE_DEPENDENCIES=( qrcode.js ext_qrcode.js form_panel.js + ckeditor.js + ext_editmode_wysiwyg_text.js ) diff --git a/libs/ckeditor5-31.0.0-k356w86hp13l.zip b/libs/ckeditor5-31.0.0-k356w86hp13l.zip new file mode 100644 index 0000000000000000000000000000000000000000..71523482a2bdfa7c0a9776eeed7aa7be010e053b Binary files /dev/null and b/libs/ckeditor5-31.0.0-k356w86hp13l.zip differ diff --git a/misc/entity_state_test_data.py b/misc/entity_state_test_data.py index 72ffe278d0ad90cb5223966bcd52c79cd420f160..400b73749011834fb5390be2e00eebdde1a91062 100755 --- a/misc/entity_state_test_data.py +++ b/misc/entity_state_test_data.py @@ -153,9 +153,8 @@ def setup_state_model(): "Transition").add_property("from", "under review").add_property("to", "unpublished").insert() # 1->1 - db.Record("Edit").add_parent( - "Transition", - description="Edit this entity. The changes are not publicly available until this entity will have been reviewed and published.").add_property( + db.Record("Edit", description="Edit this entity. The changes are not publicly available until this entity will have been reviewed and published.").add_parent( + "Transition").add_property( "from", "unpublished").add_property( "to", diff --git a/src/core/js/caosdb.js b/src/core/js/caosdb.js index 90d6bfd23b989bbff133a63d65e1e68d99f4b3bb..7c3c4300f971eae16d30748e6787843334708baa 100644 --- a/src/core/js/caosdb.js +++ b/src/core/js/caosdb.js @@ -1071,7 +1071,7 @@ function createFileXML(name, id, parents, * Update, Response, Delete. * * @param {string} root - The name of the newly created document root node. - * @param {(Document|XMLDocumentFragment)} xmls The xml documents. + * @param {Document[]|XMLDocumentFragment[]} xmls The xml documents. * @return {Document} A new xml document. */ function wrapXML(root, xmls) { @@ -1164,6 +1164,36 @@ async function update(xml) { return await transaction.updateEntitiesXml(wrapped); } + +/** + * Restore an old version of an entity using an xml representation. + * First, the old version is retrieved and the current version is set to the + * old one. + * @param versionid The version id (e.g. 123@abbabbaeff23322) of the version of + * the entity which shall be restored. + */ +async function restore_old_version(versionid){ + // retrieve entity + var ent = await transaction.retrieveEntityById(versionid); + if (ent === undefined){ + throw new Error(`Entity with version id ${versionid} could not be retrieved.`); + } + // remove unwanted tags (Version and Permissions) + ent.getElementsByTagName("Version")[0].remove(); + var permissions = ent.getElementsByTagName("Permissions"); + for (let i = permissions.length-1; i >=0 ; i--) { + permissions[i].remove(); + } + + // use XML to update entity/restore old version + const doc = _createDocument("Request"); + doc.firstElementChild.appendChild(ent); + reps = await transaction.updateEntitiesXml(doc); + if (reps.getElementsByTagName("Error").length>0) { + throw new Error(`Could not restore the Entity to the version ${versionid}.`); + } +} + /** * Insert an entity in xml representation. * diff --git a/src/core/js/edit_mode.js b/src/core/js/edit_mode.js index 85645aabaf7d05cb7abe928b4792b5de6ef9e352..87e588aab11a681e49ff4e6596a81f4037628316 100644 --- a/src/core/js/edit_mode.js +++ b/src/core/js/edit_mode.js @@ -76,11 +76,52 @@ var edit_mode = new function () { } } + this.has_edit_fragment = function () { + const fragment = window.location.hash.substr(1); + return fragment === "edit"; + } + + this.has_new_record_fragment = function () { + const fragment = window.location.hash.substr(1); + return fragment === "new_record"; + } + this._init = function () { var target = $("#top-navbar").find("ul").first(); this.add_edit_mode_button(target, edit_mode.toggle_edit_mode); + + var after_setup_callback = () => {} // do nothing + if (this.has_edit_fragment()) { + // find first entity + const first_entity = $(".caosdb-entity-panel")[0]; + if (first_entity) { + window.localStorage["edit_mode"] = true; + after_setup_callback = () => { + logger.debug("Edit this entity after #edit in the uri", first_entity); + edit_mode.edit(first_entity); + } + } + } else if (this.has_new_record_fragment()) { + for (let entity of $(".caosdb-entity-panel")) { + // find first record type + if (getEntityRole(entity) === "RecordType") { + window.localStorage["edit_mode"] = true; + const new_record = edit_mode.create_new_record(getEntityID(entity)); + after_setup_callback = () => { + logger.debug("Create a new record after #new_record in the uri", entity); + new_record.then((new_record) => { + edit_mode.app.newEntity(new_record); + }, edit_mode.handle_error); + } + break; + } + } + } + + // intialize the edit mode panel and add all necessary buttons if the edit mode is if (this.is_edit_mode()) { - edit_mode.enter_edit_mode(); + + edit_mode.enter_edit_mode().then(after_setup_callback); edit_mode.toggle_edit_panel(); // This is for the very specific case of reloading the // page while the edit mode is active on small screens @@ -88,6 +129,9 @@ var edit_mode = new function () { $(".caosdb-edit-min-width-warning").addClass("d-block"); } $('.caosdb-f-edit').css("transition", "top 1s"); + + // add drag-n-drop listener (needed for the edit_mode toolbox). + edit_mode.init_dragable(); } @@ -444,9 +488,6 @@ var edit_mode = new function () { file_path = getEntityPath(entity_form); file_checksum = getEntityChecksum(entity_form); file_size = getEntitySize(entity_form); - console.log(file_path); - console.log(file_checksum); - console.log(file_size); } return createEntityXML( entityRole, @@ -580,7 +621,6 @@ var edit_mode = new function () { $(".caosdb-f-btn-toggle-edit-mode").text("Leave Edit Mode"); edit_mode.init_tool_box(); - edit_mode.init_dragable(); var nextEditApp = editApp; if (typeof nextEditApp == "undefined") { @@ -639,6 +679,7 @@ var edit_mode = new function () { * the model given by model. */ this.init_tool_box = async function () { + // remove previously added model $(".caosdb-f-edit-mode-existing").remove() @@ -649,6 +690,12 @@ var edit_mode = new function () { removeAllWaitingNotifications(editPanel[0]); editPanel.children()[0].appendChild(model); + + if (edit_mode.app && edit_mode.app.entity && edit_mode.app.entity.parentElement && (edit_mode.app.state === "changed")) { + // an entity is being editted + $(".caosdb-f-edit-mode-existing").toggleClass("d-none", false); + $(".caosdb-f-edit-mode-create-buttons").toggleClass("d-none", true); + } } @@ -1485,6 +1532,7 @@ var edit_mode = new function () { init_drag_n_drop(); } app.onEnterInitial = async function (e) { + $(".caosdb-f-edit-mode-existing").toggleClass("d-none", true); $(".caosdb-f-edit-mode-create-buttons").toggleClass("d-none", false); app.old = undefined; @@ -1559,6 +1607,7 @@ var edit_mode = new function () { }, edit_mode.handle_error); }; app.onEnterChanged = function (e) { + // show existing entities in toolbox $(".caosdb-f-edit-mode-existing").toggleClass("d-none", false); $(".caosdb-f-edit-mode-create-buttons").toggleClass("d-none", true); @@ -1755,7 +1804,7 @@ var edit_mode = new function () { * which can be referenced by the property. */ this.retrieve_datatype_list = async function (datatype) { - var find_entity = ["FILE", "REFERENCE"].includes(datatype) ? "" : datatype; + var find_entity = ["FILE", "REFERENCE"].includes(datatype) ? "" : `"${datatype}"`; var entities = datatype !== "FILE" ? await edit_mode.query(`FIND Record ${find_entity}`) : []; var files = await edit_mode.query(`FIND File ${find_entity}`); diff --git a/src/core/js/ext_autocomplete.js b/src/core/js/ext_autocomplete.js index 9a639fb4387a32bad128406bb7bd1f036bed5fda..2f6fa0dde729907f7248bd05367f26633872f39e 100644 --- a/src/core/js/ext_autocomplete.js +++ b/src/core/js/ext_autocomplete.js @@ -158,8 +158,16 @@ var ext_autocomplete = new function () { var start = newValue.slice(0, beginning_of_word + 1); var end = origJQElement[0].value.slice(cursorpos); var result = resultsFromServer.map(x => { + var x_quoted = x; + if (x.indexOf(" ") > -1) { + if(x.indexOf("\"") > -1) { + x_quoted = `'${x}'`; + } else { + x_quoted = `"${x}"`; + } + } return { - text: start + x + end, + text: start + x_quoted + end, html: x } }); @@ -196,4 +204,4 @@ $(document).ready(function () { if ("${BUILD_MODULE_EXT_AUTOCOMPLETE}" == "ENABLED") { caosdb_modules.register(ext_autocomplete); } -}); \ No newline at end of file +}); diff --git a/src/core/js/ext_cosmetics.js b/src/core/js/ext_cosmetics.js index f4f281123b39a87b7ef6848db4e84a81b5e30d9c..77556437394df6a6763661ce5c0d5001f68ce61a 100644 --- a/src/core/js/ext_cosmetics.js +++ b/src/core/js/ext_cosmetics.js @@ -47,7 +47,8 @@ var cosmetics = new function () { return `<a title="Open ${href} in a new tab." target="_blank" class="caosdb-v-property-href-value" href="${href}">${link_text} <i class="bi bi-box-arrow-up-right"></i></a>`; }); - $(this).html(result); + $(this).hide(); + $(this).after(result); } }); } diff --git a/src/core/js/ext_editmode_wysiwyg_text.js b/src/core/js/ext_editmode_wysiwyg_text.js new file mode 100644 index 0000000000000000000000000000000000000000..380d8b1ec298ed0fa5c68af50b8b0e9dad333b89 --- /dev/null +++ b/src/core/js/ext_editmode_wysiwyg_text.js @@ -0,0 +1,118 @@ +/* + * This file is a part of the CaosDB Project. + * + * Copyright (C) 2021 IndiScale GmbH <info@indiscale.com> + * Copyright (C) 2021 Florian Spreckelsen <f.spreckelsen@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/>. + */ + +"use strict"; + +/** + * Replaces textareas in the edit mode by a wysiwyg editor + * + * @module ext_editmode_wysiwyg_text + * @version 0.1 + * + * @param jQuery - well-known library. + * @param log - singleton from loglevel library or javascript console. + * @param {class} ClassicEditor - ClassicEditor class from ckEditor + * @param {module} edit_mode - caosdb's edit-mode module + * @param {function} getPropertyElements - caosdb's function to extract the + * property HTML elements from an entity HTML element + * @param {function} getPropertyDatatype - caosdb's function to extract the + * data type from a property HTML element + * @param {function} getPropertyName - caosdb's function to extract the + * name from a property HTML element + */ +var ext_editmode_wysiwyg_text = function ($, logger, ClassicEditor, edit_mode, getPropertyElements, getPropertyDatatype, getPropertyName) { + + var insertEditorInProperty = async function (prop) { + if (!(getPropertyDatatype(prop) === 'TEXT')) { + // Ignore anything that isn't a list property, even LIST<TEXT> + return; + } + + try{ + const editor = await ClassicEditor + .create(prop.querySelector('textarea'), { + // use all plugins since we built the editor dependency to + // contain only those we need. + plugins: ClassicEditor.builtinPlugins, + // Markdown needs a header row so enforce this + table: { + defaultHeadings: { + rows: 1, + columns: 0 + }, + }, + }) + logger.debug('Initialized editor for ' + getPropertyName(prop)); + // Manually implement saving the data since edit mode is not + // a form to be submitted. + editor.model.document.on("change:data", (e) => { + editor.updateSourceElement(); + }); + } catch(error) { + logger.error(error.stack); + } + } + + var replaceTextAreas = function (entity) { + const properties = getPropertyElements(entity); + for (let prop of properties) { + // TODO(fspreck): This will be replaced by a whitelist of properties + // in the future. + if (getPropertyName(prop)) { + insertEditorInProperty(prop); + } + } + } + + var init = function () { + + // Insert an editor into all TEXT properties of the entity which is + // being edited. + document.body.addEventListener(edit_mode.start_edit.type, (e) => { + logger.debug('Replacing text areas ...'); + ext_editmode_wysiwyg_text.replaceTextAreas(e.target); + }, true); + + // Listen to added properties and replace the textarea if necessary + document.body.addEventListener(edit_mode.property_added.type, (e) => { + logger.debug('Replacing textarea in ' + getPropertyName(e.target)); + ext_editmode_wysiwyg_text.insertEditorInProperty(e.target); + }, true) + + // Listen to properties, the data type of which has changed. Mainly + // because of change from list to scalar and vice versa. + document.body.addEventListener(edit_mode.property_data_type_changed.type, (e) => { + logger.debug('Re-rendering ' + getPropertyName(e.target)); + ext_editmode_wysiwyg_text.insertEditorInProperty(e.target); + }, true); + }; + + return { + init: init, + replaceTextAreas: replaceTextAreas, + insertEditorInProperty: insertEditorInProperty, + }; +}($, log.getLogger("ext_editmode_wysiwyg_text"), ClassicEditor, edit_mode, getPropertyElements, getPropertyDatatype, getPropertyName); + +$(document).ready(() => { + if ("${BUILD_MODULE_EXT_EDITMODE_WYSIWYG_TEXT}" == "ENABLED") { + caosdb_modules.register(ext_editmode_wysiwyg_text); + } +}); diff --git a/src/core/js/form_panel.js b/src/core/js/form_panel.js index dabb28c9d9dad79a3768fde7552d483bdd3bf570..9728a4ccea54c36d85399a3148373b7372108db0 100644 --- a/src/core/js/form_panel.js +++ b/src/core/js/form_panel.js @@ -47,7 +47,7 @@ var form_panel = new function () { if (existing.length > 0) { return existing[0]; } - const panel = $('<div id="' + panel_id + '" class="caosdb-f-form-panel bg-light container"/>'); + const panel = $('<div id="' + panel_id + '" class="caosdb-f-form-panel bg-light container mb-1"/>'); const header = $('<h2 class="text-center">' + title + '</h2>'); panel.append(header); diff --git a/src/core/js/webcaosdb.js b/src/core/js/webcaosdb.js index efa28c9b39921df3e02a25ec95d4601f752fa288..03cc7bfbb399e898a88cb99dc0a05cc90289f96a 100644 --- a/src/core/js/webcaosdb.js +++ b/src/core/js/webcaosdb.js @@ -359,6 +359,8 @@ this.caosdb_utils = new function () { * connection module contains all ajax calls. */ this.connection = new function () { + const logger = log.getLogger("connection"); + this._init = function () { /** * Send a get request. @@ -376,7 +378,7 @@ this.connection = new function () { if (error.status == 414) { throw new Error("UriTooLongException for GET " + uri); } else if (error.status == 0) { - console.log(error); + logger.error(error); } else if (error.status != null) { throw new Error("GET " + uri + " returned with HTTP status " + error.status + " - " + error.statusText); } else { @@ -400,7 +402,7 @@ this.connection = new function () { }); } catch (error) { if (error.status == 0) { - console.log(error); + logger.error(error); } else if (error.status != null) { throw new Error("PUT " + uri + " returned with HTTP status " + error.status + " - " + error.statusText); } else { @@ -435,7 +437,7 @@ this.connection = new function () { }); } catch (error) { if (error.status == 0) { - console.log(error); + logger.error(error); } else if (error.status != null) { throw new Error( "POST scripting returned with HTTP status " + error.status + @@ -461,7 +463,7 @@ this.connection = new function () { }); } catch (error) { if (error.status == 0) { - console.log(error); + logger.error(error); } else if (error.status != null) { throw new Error("POST " + uri + " returned with HTTP status " + error.status + " - " + error.statusText); } else { @@ -486,7 +488,7 @@ this.connection = new function () { }); } catch (error) { if (error.status == 0) { - console.log(error); + logger.error(error); } else if (error.status != null) { throw new Error("DELETE " + "Entity/" + idline + " returned with HTTP status " + error.status + " - " + error.statusText); } else { @@ -985,6 +987,9 @@ this.transaction = new function () { */ var version_history = new function () { + const logger = log.getLogger("version_history"); + this.logger = logger; + this._get = connection.get; /** * Retrieve the version history of an entity and return a table with the @@ -1035,6 +1040,7 @@ var version_history = new function () { .retrieve_history(entity_id_version); sparse.replaceWith(history_table); version_history.init_export_history_buttons(entity); + version_history.init_restore_version_buttons(entity); }); } } @@ -1072,7 +1078,7 @@ var version_history = new function () { for (let version_info of $(entity) .find(".caosdb-f-entity-version-info")) { $(version_info).find(".caosdb-f-entity-version-export-history-btn") - .click(async () => { + .click(() => { const html_table = $(version_info).find("table")[0]; const history_tsv = this.get_history_tsv(html_table); version_history._download_tsv(history_tsv); @@ -1080,6 +1086,72 @@ var version_history = new function () { } } + /** + * Initialize the restore old version buttons of `entity`. + * + * The buttons are only visible when the user is allowed to update the + * entity. + * + * The causes a retrieve of the specified version of the entity and then an + * update that restores that version. + * + * @param {HTMLElement} [entity] - if undefined, the export buttons of all + * page entities are initialized. + */ + this.init_restore_version_buttons = function (entity) { + var entities = [entity] || $(".caosdb-entity-panel"); + + for (let _entity of entities) { + // initialize buttons only if the user is allowed to update the entity + if (hasEntityPermission(_entity, "UPDATE:*") || hasEntityPermission(_entity, "UPDATE:DESCRIPTION")) { + for (let version_info of + $(_entity).find(".caosdb-f-entity-version-info")) { + // find the restore button + $(version_info).find(".caosdb-f-entity-version-restore-btn") + .toggleClass("d-none", false) // show button + .click(async (eve) => { + // the version id is stored in the restore button's + // data-version-id attribute + const versionid = eve.delegateTarget.getAttribute("data-version-id") + const reload = () => { + window.location.reload(); + } + const _alert = form_elements.make_alert({ + title: "Warning", + message: "You are going to restore this version of the entity.", + proceed_callback: async () => { + try { + await restore_old_version(versionid); + $(_alert).remove(); + // reload after sucessful update + $(version_info).find(".modal-body").prepend( + $(`<div class="alert alert-success" role="alert">Restore successful! <p>You are being forwarded to the latest version of this entity or you can click <a href="#" onclick="window.location.reload()">here</a>.</p></div>`)); + setTimeout(reload, 5000); + } catch (e) { + logger.error(e); + // print errors in an alert div + $(version_info).find(".modal-body").prepend( + $(`<div class="alert alert-danger alert-dismissible " role="alert"> <button class="btn-close" data-bs-dismiss="alert" aria-label="close"></button> Restore failed! <p>${e.message}</p></div>`)); + + } + }, + cancel_callback: () => { + // do nothing + $(_alert).remove(); + $(version_info).find("table").show(); + }, + proceed_text: "Yes, restore!", + remember_my_decision_id: "restore_entity", + }); + + $(version_info).find("table").after(_alert).hide(); + $(_alert).addClass("text-end"); + }); + } + } + } + } + this._download_tsv = function (tsv_link) { window.location.href = tsv_link; } @@ -1088,6 +1160,7 @@ var version_history = new function () { this.init = function () { this.init_load_history_buttons(); this.init_export_history_buttons(); + this.init_restore_version_buttons(); } } diff --git a/src/core/xsl/entity.xsl b/src/core/xsl/entity.xsl index 92f08ea70645a8282f97f364c9fc4143f37afd6a..d8ba00a9810e9861bfba741055ea113c7f2b88bc 100644 --- a/src/core/xsl/entity.xsl +++ b/src/core/xsl/entity.xsl @@ -644,11 +644,14 @@ <div class="modal-body"> <table class="table table-hover"> <thead> - <tr><th><div class="export-data">Entity ID</div></th> + <tr> + <th></th> + <th class="invisible"><div class="export-data">Entity ID</div></th> <th class="export-data">Version ID</th> <th class="export-data">Date</th> <th class="export-data">User</th> <th class="invisible"><div class="export-data">URI</div></th> + <th></th> </tr></thead> <tbody> <xsl:apply-templates mode="entity-version-modal-successor" select="Successor"> @@ -664,6 +667,13 @@ <xsl:value-of select="@username"/>@<xsl:value-of select="@realm"/> </td> <td class="invisible"><div class="export-data"><xsl:value-of select="concat($entitypath, $entityId, '@', @id)"/></div></td> + <td> + <xsl:if test="not(@head='true')"> + <button type="button" class="caosdb-f-entity-version-restore-btn btn btn-secondary d-none" title="Restore this version of the entity."> + <xsl:attribute name="data-version-id"><xsl:value-of select="$entityId"/>@<xsl:value-of select="@id"/></xsl:attribute> + <i class="bi-arrow-counterclockwise"></i></button> + </xsl:if> + </td> </tr> <xsl:apply-templates mode="entity-version-modal-predecessor" select="Predecessor"> <xsl:with-param name="entityId" select="$entityId"/> @@ -672,7 +682,7 @@ </table> </div> <div class="modal-footer"> - <button type="button" class="caosdb-f-entity-version-export-history-btn btn btn-secondary">Export history</button> + <button type="button" class="caosdb-f-entity-version-export-history-btn btn btn-secondary" title="Export this history table as a CSV file.">Export history</button> </div> </xsl:template> @@ -745,6 +755,14 @@ <xsl:value-of select="@username"/>@<xsl:value-of select="@realm"/> </td> <td class="invisible"><div class="export-data"><xsl:value-of select="concat($entitypath, $entityId, '@', @id)"/></div></td> + <td> + <!-- include button if it is not head, i.e. Predecessors are always old and Successors if they do have a Successor Member --> + <xsl:if test="(name()='Predecessor' or Successor)"> + <button type="button" class="caosdb-f-entity-version-restore-btn btn btn-secondary d-none" title="Restore this version of the entity."> + <xsl:attribute name="data-version-id"><xsl:value-of select="$entityId"/>@<xsl:value-of select="@id"/></xsl:attribute> + <i class="bi-arrow-counterclockwise"></i></button> + </xsl:if> + </td> </tr> </xsl:template> diff --git a/src/core/xsl/footer.xsl b/src/core/xsl/footer.xsl index 0231649d4ff4deda68822eecf25576175a5eed00..ce491222ca8e018ced18167bdfc9e934c37fa4d8 100644 --- a/src/core/xsl/footer.xsl +++ b/src/core/xsl/footer.xsl @@ -35,15 +35,17 @@ </div> </div> <div class="container d-flex flex-md-row flex-column justify-content-center"> - <a href="mailto:info@indiscale.com">Contact</a> + <a href="${BUILD_FOOTER_CONTACT_HREF}">Contact</a> <span class="caosdb-bulletsep d-none d-md-inline">•</span> - <a href="https://www.indiscale.com/imprint/">Imprint/Impressum</a> + <a href="${BUILD_FOOTER_IMPRINT_HREF}">Imprint/Impressum</a> <span class="caosdb-bulletsep d-none d-md-inline">•</span> <a href="${BUILD_FOOTER_DATA_POLICY_HREF}">Data Policy</a> <span class="caosdb-bulletsep d-none d-md-inline">•</span> - <a href="https://www.gnu.org/licenses/agpl-3.0.en.html" target="_blank">License (AGPL-v3)</a> + <a href="${BUILD_FOOTER_LICENCE_HREF}" target="_blank">License (AGPL-v3)</a> <span class="caosdb-bulletsep d-none d-md-inline">•</span> - <a href="https://gitlab.com/caosdb" target="_blank">Sources</a> + <a href="${BUILD_FOOTER_SOURCES_HREF}" target="_blank">Sources</a> + <span class="caosdb-bulletsep d-none d-md-inline">•</span> + <a href="${BUILD_FOOTER_DOCS_HREF}" target="_blank">Documentation</a> </div> </xsl:template> </xsl:stylesheet> diff --git a/test/core/js/modules/edit_mode.js.js b/test/core/js/modules/edit_mode.js.js index 3a87ee73d167d114d0b7db2b0abe50e4a643f8fa..ae1a51d837348ba0ba9c31f48a28a69ef2c9ad7b 100644 --- a/test/core/js/modules/edit_mode.js.js +++ b/test/core/js/modules/edit_mode.js.js @@ -400,8 +400,28 @@ QUnit.test("unfreeze", function (assert) { assert.ok(edit_mode.unfreeze); }); -QUnit.test("retrieve_datatype_list", function (assert) { +QUnit.test("retrieve_datatype_list", async function (assert) { assert.ok(edit_mode.retrieve_datatype_list); + var query_done; + + edit_mode.query = function (query) { + var re = /^FIND (Record|File) "IceCore"$/g; + assert.ok(query.match(re), `${query} should match ${re}`); + query_done(); + return []; + } + query_done = assert.async(2); + await edit_mode.retrieve_datatype_list("IceCore"); + + + edit_mode.query = function (query) { + var re = /^FIND (Record|File) "Ice Core"$/g; + assert.ok(query.match(re), `${query} should match ${re}`); + query_done(); + return []; + } + query_done = assert.async(2); + await edit_mode.retrieve_datatype_list("Ice Core"); }); QUnit.test("highlight", function (assert) { diff --git a/test/core/js/modules/ext_autocomplete.js.js b/test/core/js/modules/ext_autocomplete.js.js index e8776f945b7bb46a0d431eb2d0ac0f7fe21419fc..96cab766fb848b74b04677f9b3312b574b9a3844 100644 --- a/test/core/js/modules/ext_autocomplete.js.js +++ b/test/core/js/modules/ext_autocomplete.js.js @@ -25,7 +25,7 @@ QUnit.module("ext_autocomplete.js", { before: function (assert){ ext_autocomplete.retrieve_names = async function () { - return ['IceCore', 'Bag', 'IceSample', 'IceCream', 'Palette']; + return ['IceCore', 'Bag', 'IceSample', 'IceCream', 'Palette', 'Ice Core']; } ext_autocomplete.init(); @@ -60,12 +60,34 @@ QUnit.test("search", async function(assert) { }; }; await ext_autocomplete.search("Ice", - gcallback( ['IceCore', 'IceSample', 'IceCream']) + gcallback( ['IceCore', 'IceSample', 'IceCream', 'Ice Core']) ); await ext_autocomplete.search("Core", gcallback([])); }); +QUnit.test("searchPost", async function(assert) { + const resultsFromServer = ["Ice Core", "IceCore"]; + const origJQElement = [{ + selectionEnd: 8, + value: "FIND Ice WHERE", + }]; + + const expected = [ + { + "html": "Ice Core", + "text": "FIND \"Ice Core\" WHERE" + }, + { + "html": "IceCore", + "text": "FIND IceCore WHERE" + } + ]; + + const result = ext_autocomplete.searchPost(resultsFromServer, origJQElement); + assert.propEqual(result, expected); +}); + QUnit.test("class", function(assert) { assert.ok(ext_autocomplete.switch_on_completion , "toggle available"); assert.ok(ext_autocomplete.switch_on_completion() , "toggle runs"); diff --git a/test/core/js/modules/ext_cosmetics.js.js b/test/core/js/modules/ext_cosmetics.js.js index d5d4df7f10a2859bcd7318680d4f6720aedc6127..969c8297b8b5cf85d0a668d7c30e8b0f45e34d4d 100644 --- a/test/core/js/modules/ext_cosmetics.js.js +++ b/test/core/js/modules/ext_cosmetics.js.js @@ -46,13 +46,16 @@ QUnit.test("linkify - https", function (assert) { ["this is other text https://link", 1], ["this is other text https://link and here comes another link https://link and more text", 2], ]; + for (let test_case of test_cases) { - var text_value = $(`<div class="caosdb-f-property-text-value">${test_case[0]}</div>`); - $(document.body).append(text_value); - assert.equal($(text_value).find("a[href='https://link']").length, 0, "no link present"); + const container = $('<div></div>'); + $(document.body).append(container); + const text_value = $(`<div class="caosdb-f-property-text-value">${test_case[0]}</div>`); + container.append(text_value); + assert.equal($(container).find("a[href='https://link']").length, 0, "no link present"); cosmetics.linkify(); - assert.equal($(text_value).find("a[href='https://link']").length, test_case[1], "link is present"); - text_value.remove(); + assert.equal($(container).find("a[href='https://link']").length, test_case[1], "link is present"); + container.remove(); } }); @@ -66,22 +69,26 @@ QUnit.test("linkify - http", function (assert) { ["this is other text http://link and here comes another link http://link and more text", 2], ]; for (let test_case of test_cases) { - var text_value = $(`<div class="caosdb-f-property-text-value">${test_case[0]}</div>`); - $(document.body).append(text_value); - assert.equal($(text_value).find("a[href='http://link']").length, 0, "no link present"); + const container = $('<div></div>'); + $(document.body).append(container); + const text_value = $(`<div class="caosdb-f-property-text-value">${test_case[0]}</div>`); + $(container).append(text_value); + assert.equal($(container).find("a[href='http://link']").length, 0, "no link present"); cosmetics.linkify(); - assert.equal($(text_value).find("a[href='http://link']").length, test_case[1], "link is present"); - text_value.remove(); + assert.equal($(container).find("a[href='http://link']").length, test_case[1], "link is present"); + container.remove(); } }); QUnit.test("linkify cut-off (40)", function (assert) { - var test_case = "here is some text https://this.is.a.link/with/more/than/40/characters/ this is more text"; - var text_value = $(`<div class="caosdb-f-property-text-value">${test_case}</div>`); - $(document.body).append(text_value); - assert.equal($(text_value).find("a").length, 0, "no link present"); + const container = $('<div></div>'); + $(document.body).append(container); + const test_case = "here is some text https://this.is.a.link/with/more/than/40/characters/ this is more text"; + const text_value = $(`<div class="caosdb-f-property-text-value">${test_case}</div>`); + $(container).append(text_value); + assert.equal($(container).find("a").length, 0, "no link present"); cosmetics.linkify(); - assert.equal($(text_value).find("a").length, 1, "link is present"); - assert.equal($(text_value).find("a").text(), "https://this.is.a.link/with/more/th[...] ", "link text has been cut off"); - text_value.remove(); -}); \ No newline at end of file + assert.equal($(container).find("a").length, 1, "link is present"); + assert.equal($(container).find("a").text(), "https://this.is.a.link/with/more/th[...] ", "link text has been cut off"); + container.remove(); +}); diff --git a/test/core/js/modules/webcaosdb.js.js b/test/core/js/modules/webcaosdb.js.js index d2ef27952e41142a62eb70e144571bc9d30c52d2..5f45c32adb9d171c5d362467d0bba0840f02836f 100644 --- a/test/core/js/modules/webcaosdb.js.js +++ b/test/core/js/modules/webcaosdb.js.js @@ -1827,6 +1827,7 @@ QUnit.test("available", function (assert) { assert.equal(typeof version_history.init, "function"); assert.equal(typeof version_history.get_history_tsv, "function"); assert.equal(typeof version_history.init_export_history_buttons, "function"); + assert.equal(typeof version_history.init_restore_version_buttons, "function"); assert.equal(typeof version_history.init_load_history_buttons, "function"); assert.equal(typeof version_history.retrieve_history, "function"); }) @@ -1887,6 +1888,76 @@ QUnit.test("init_load_history_buttons and init_load_history_buttons", async func $(html).remove(); }); +QUnit.test("available", function (assert) { + assert.equal(typeof restore_old_version, "function"); +}) + +QUnit.test("init_restore_version_buttons", async function (assert) { + var xml_str = `<Response username="user1" realm="Realm1" srid="bc2f8f6b-71d6-49ca-890c-eebea3e38e18" timestamp="1606253365632" baseuri="https://localhost:10443" count="1"> + <UserInfo username="user1" realm="Realm1"> + <Roles> + <Role>role1</Role> + </Roles> + </UserInfo> + <Record id="8610" name="TestRecord1-6thVersion" description="This is the 6th version."> + <Permissions> + <Permission name="RETRIEVE:HISTORY" /> + <Permission name="UPDATE:*" /> + </Permissions> + <Version id="efa5ac7126c722b3f43284e150d070d6deac0ba6" > + <Predecessor id="f09114b227d88f23d4e23645ae471d688b1e82f7" /> + <Successor id="5759d2bccec3662424db5bb005acea4456a299ef" /> + </Version> + <Parent id="8609" name="TestRT" /> + </Record> +</Response> +`; + var done = assert.async(1); + var xml = str2xml(xml_str); + version_history._get = async function (entity) { + assert.equal(entity, "Entity/8610@efa5ac7126c722b3f43284e150d070d6deac0ba6?H"); + done(); + $(xml).find("Version").attr("completeHistory", "true"); + return xml; + } + var html = await transformation.transformEntities(xml); + var load_button = $(html).find(".caosdb-f-entity-version-load-history-btn"); + $("body").append(html); + + assert.notOk(load_button.is(":visible"), "load_button hidden"); + load_button.click(); // nothing happens + + version_history.init_load_history_buttons(); + assert.ok(load_button.is(":visible"), "load_button is not hidden anymore"); + + //console.log(xml2str(restore_button[0])); + //assert.ok(restore_button.hasClass("d-none"), "restore_button is hidden"); + + + // load_button triggers retrieval of history + load_button.click(); + await sleep(500); + + //console.log(xml2str(restore_button[0])); + //version_history.init_restore_version_buttons(); + + var restore_button = $("body").find(".caosdb-f-entity-version-restore-btn"); + assert.ok(!restore_button.hasClass("d-none"), "restore_button is not hidden anymore"); + + // restore_button triggers retrieval of history + localStorage["form_elements.alert_decision.restore_entity"] = "proceed"; + restore_button.first().click(); + localStorage.removeItem("form_elements.alert_decision.restore_entity"); + await sleep(500); + + // restore is not possible in the unit test + alertdiv = $(html).find(".alert-danger"); + assert.equal(alertdiv.length, 1, "on alert div"); + assert.ok(alertdiv.text().indexOf("Restore failed") > 0, "Restore failed"); + + $(html).remove(); +}); + /* SETUP tests for user_management */ QUnit.module("webcaosdb.js - user_management", {