diff --git a/CHANGELOG.md b/CHANGELOG.md index af7186a241abbca849299410816b97e56be16de4..9f361607ef407170396c244a3fa98730fed4c46d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added (for new features, dependecies etc.) +### Changed (for changes in existing functionality) + +### Deprecated (for soon-to-be removed features) + +### Removed (for now removed features) + +### Security (in case of vulnerabilities) + +## v0.2-rc.1 (2020-04-10) + +### Added (for new features, dependecies etc.) + +* ext_bottom_line module (v0.1 - Experimental) + * A module which adds a preview section where any kind of summarizing + information (like plots, video players, thumbnails...) can be shown. + * Enable with build property `BUILD_MODULE_EXT_BOTTOM_LINE=ENABLED`. + * More documentation in `src/core/js/ext_bottom_line.js`. +* ext_revisions (v0.1 - EXPERIMENTAL) + * Creates a backup copy of each entity which is updated via the edit_mode. + * Enable via the build property `BUILD_MODULE_EXT_REVISIONS=ENABLED`. + * Needs two special entities. A RecordType "Obsolete" and a reference + property "revisionOf" with data type "Obsolete". * Map (v0.3) * Adds a button to the navbar which toggles a map in the top of the main panel. The map currently shows all known entities which have geolocation @@ -70,9 +92,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Deprecated (for soon-to-be removed features) +* Image Preview in the FileSystem. The functionality is to be replaced by real + thumbnails, which cover also non-image data-formats. The thumbnails resource + is part of the new file system API of the CaosDB Server which is currently + under development. + ### Removed (for now removed features) * Removed non-informative tests for webcaosdb.css +* Hard-coded image and video preview in the entity panel. The preview of images + and videos is controlled by the `ext_bottom_line` module now. ### Fixed (for any bug fixes) diff --git a/build.properties.d/00_default.properties b/build.properties.d/00_default.properties index 0ce20ba28dc6fb31a427d0c6aed04ff100f31457..046375fa9718c1acdc0a30cf46d488a8beaf3a2b 100644 --- a/build.properties.d/00_default.properties +++ b/build.properties.d/00_default.properties @@ -60,7 +60,14 @@ BUILD_FOOTER_DATA_POLICY_HREF=https://indiscale.com/?page_id=156 # Custom footer elements can be placed here (will be placed inside a <div> # element). BUILD_FOOTER_CUSTOM_ELEMENT_ONE= +# BUILD_FOOTER_CUSTOM_ELEMENT_ONE='<p>Some content <img +# src="/webinterface/${BUILD_NUMBER}/pics/some_image.png"/>' + +# Files from the `build.properties.files` directory can also be included here +# These files will also have a second step of variable substitution with +# ${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>' diff --git a/build.properties.files/footer_element_2.html b/build.properties.files/footer_element_2.html new file mode 100644 index 0000000000000000000000000000000000000000..f0444d4291772d585049257631f779b3282fd4e1 --- /dev/null +++ b/build.properties.files/footer_element_2.html @@ -0,0 +1,7 @@ +<h1>Put your template content files into this directory</h1> + +<p> + Files in this directory can be substituted into the variables defined in + build.properties.d. In a second step, parameter substitution takes place with + BUILD_NUMBER, if the word is preceded by a dollar sign: ${BUILD_NUMBER} +</p> diff --git a/conf/core/json/ext_bottom_line.json b/conf/core/json/ext_bottom_line.json new file mode 100644 index 0000000000000000000000000000000000000000..528402f7b382ea40ad3ffe679606c5bb029ce7ea --- /dev/null +++ b/conf/core/json/ext_bottom_line.json @@ -0,0 +1,20 @@ +{ "version": 0.1, + "creators": [ + { "id": "test.success", + "is_applicable": "(entity) => getParents(entity).map(par => par.name).includes('TestPreviewRecordType') && getEntityName(entity) === 'TestPreviewRecord-success'", + "create": "(entity) => 'SUCCESS'" + }, + { "id": "test.error", + "is_applicable": "(entity) => getParents(entity).map(par => par.name).includes('TestPreviewRecordType') && getEntityName(entity) === 'TestPreviewRecord-error'", + "create": "(entity) => new Promise((res,rej) => {rej('Test Error');})" + }, + { "id": "test.load-forever", + "is_applicable": "(entity) => getParents(entity).map(par => par.name).includes('TestPreviewRecordType') && getEntityName(entity) === 'TestPreviewRecord-load-forever'", + "create": "(entity) => new Promise((res,rej) => {})" + }, + { "id": "test.success-2", + "is_applicable": "(entity) => getParents(entity).map(par => par.name).includes('TestPreviewRecordType') && getEntityName(entity) !== 'TestPreviewRecord-fall-back'", + "create": "(entity) => { return plotly_preview.create_plot([{x: [1,2,3,4,5], y: [1,2,4,8,16]}]); }" + } + ] +} diff --git a/libs/plotly.js-1.52.2.zip b/libs/plotly.js-1.52.2.zip new file mode 100644 index 0000000000000000000000000000000000000000..f7588bf63dd9e685fec3180fe3336829f34e566b Binary files /dev/null and b/libs/plotly.js-1.52.2.zip differ diff --git a/makefile b/makefile index c7d7c91aba376d22b1e151102d84a7c2bb551278..d9091f32004cfe9c25e28c9e75a3cc18471ac73b 100644 --- a/makefile +++ b/makefile @@ -40,7 +40,7 @@ SRC_EXT_DIR = $(abspath src/ext) LIBS_DIR = $(abspath libs) TEST_CORE_DIR = $(abspath test/core/) TEST_EXT_DIR = $(abspath test/ext) -LIBS = fonts css/bootstrap.css js/bootstrap.js 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 +LIBS = fonts css/bootstrap.css js/bootstrap.js 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 TEST_LIBS = $(LIBS) js/qunit.js css/qunit.css $(subst $(TEST_CORE_DIR)/,,$(shell find $(TEST_CORE_DIR)/)) @@ -66,16 +66,20 @@ merge_xsl: build_properties: @set -a -e ; \ - for f in $$(ls build.properties.d/) ; do source "build.properties.d/$$f" ; done ; \ + pushd build.properties.files ; \ + for f in ../build.properties.d/* ; do source "$$f" ; done ; \ + popd ; \ BUILD_NUMBER=$(BUILD_NUMBER) ; \ PROPS=$$(printenv | grep -e "^BUILD_") ; \ echo "SET BUILD PROPERTIES:" ; \ for p in $$PROPS ; do echo " $$p" ; done; \ - VARS=$$(printenv | grep -e "^BUILD_" | sed 's/=.*$$/}/' | sed 's/^/$${/') ; \ + VARS=$$(printenv | grep -e "^BUILD_" | grep -v "^BUILD_NUMBER=" | \ + sed -E 's/(BUILD_[^=]+)=.*/$${\1}/') ; \ for f in $$(find $(PUBLIC_DIR) -type f) ; do \ echo "BUILD FILE: $$f" ; \ mv "$$f" "$${f}.tmp" ; \ - envsubst "$$VARS" < "$${f}.tmp" > "$$f" ; \ + envsubst "$$VARS" < "$${f}.tmp" | \ + envsubst "\$${BUILD_NUMBER}" > "$$f" ; \ rm "$${f}.tmp" ; \ done @echo $(BUILD_NUMBER) > $(PUBLIC_DIR)/.build_number @@ -252,9 +256,12 @@ $(LIBS_DIR)/js/leaflet-coordinates.js: unzip $(LIBS_DIR)/js $(LIBS_DIR)/css/leaflet-coordinates.css: unzip $(LIBS_DIR)/css ln -s $(LIBS_DIR)/Leaflet.Coordinates-0.1.5/dist/Leaflet.Coordinates-0.1.5.css $@ -$(LIBS_DIR)/js/bootstrap-autocomplete.min.js: unzip $(LIBS_DIR)/css +$(LIBS_DIR)/js/bootstrap-autocomplete.min.js: unzip $(LIBS_DIR)/js ln -s $(LIBS_DIR)/bootstrap-autocomplete-2.3.0/dist/latest/bootstrap-autocomplete.min.js $@ +$(LIBS_DIR)/js/plotly.js: unzip $(LIBS_DIR)/js + ln -s $(LIBS_DIR)/plotly.js-1.52.2/dist/plotly.min.js $@ + $(addprefix $(LIBS_DIR)/, js css): mkdir $@ || true @@ -268,7 +275,7 @@ clean: .PHONY: unzip unzip: - for f in $(LIBS_ZIP); do unzip -o -d libs $$f; done + for f in $(LIBS_ZIP); do unzip -q -o -d libs $$f; done PYLINT = pylint3 -d all -e E,F diff --git a/misc/ext_bottom_line_test_data.py b/misc/ext_bottom_line_test_data.py new file mode 100755 index 0000000000000000000000000000000000000000..1a43094eb6719e2b7b2a6a4ab42d47fcc415d513 --- /dev/null +++ b/misc/ext_bottom_line_test_data.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# ** header v3.0 +# This file is a part of the CaosDB Project. +# +# Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> +# Copyright (C) 2020 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 + +import os +import caosdb +import random + +filename = "small.webm" +if not os.path.isfile(filename): + import wget #pylint: disable=E0401 + filename = wget.download("http://techslides.com/demos/sample-videos/small.webm") +caosdb.configure_connection(ssl_insecure=True) + +# clean +old = caosdb.execute_query("FIND Test*") +if len(old): + old.delete() + +# data model +datamodel = caosdb.Container() +datamodel.extend([ + caosdb.RecordType("TestPreviewRecordType") +]) + +datamodel.insert() + + +# test data +testdata = caosdb.Container() + +## record with references +refrec = caosdb.Record("TestPreviewRecord-references").add_parent("TestPreviewRecordType") +testdata.append(refrec) + +video = caosdb.File(name="TestFileVideo", file=filename, path="testfile.webm") +image = caosdb.File(name="TestFileImage", + file="../src/core/pics/map_tile_caosdb_logo.png", + path="testfile.png") +for i in ["load-forever", "fall-back", "error", "success", "success-2"]: + rec = caosdb.Record("TestPreviewRecord-{}".format(i)).add_parent("TestPreviewRecordType") + testdata.append(rec) + refrec.add_property("TestPreviewRecordType", rec) + +testdata.append(image) +testdata.append(video) + + +testdata.insert(); + +os.remove(filename) + + 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_bottom_line.js b/src/core/js/ext_bottom_line.js new file mode 100644 index 0000000000000000000000000000000000000000..900263366e456c8f7a2945af6a24c7a10b345c0e --- /dev/null +++ b/src/core/js/ext_bottom_line.js @@ -0,0 +1,512 @@ +/* + * ** header v3.0 + * This file is a part of the CaosDB Project. + * + * Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> + * Copyright (C) 2020 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'; + + +/** + * Add a special section to each entity one the current page where a thumbnail, + * a video preview or any other kind of summary for the entity is shown. + * + * Apart from some defaults, the content for the bottom line has to be + * configured in the file `conf/ext/json/ext_bottom_line.json` which must + * contain a {@link BottomLineConfig}. An example is located at + * `conf/core/json/ext_bottom_line.json`. + * + * @module ext_bottom_line + * @version 0.1 + * + * @requires jQuery (library) + * @requires log (singleton from loglevel library) + * @requires resolve_references.is_in_viewport_vertically (funtion from ext_references.js) + * @requires load_config (function from caosdb.js) + * @requires getEntityPath (function from caosdb.js) + * @requires connection (module from webcaosdb.js) + */ +var ext_bottom_line = function ($, logger, is_in_view_port, load_config, getEntityPath, connection) { + + /** + * @property {string|function} create - a function with one parameter + * (entity) Note: This property can as well be a + * javascript string which evaluates to a function. + */ + /** + * @type {BottomLineConfig} + * @property {string|HTMLElement} fallback - Fallback content if none of + * the creators are applicable. + * @property {string} version - the version of the configuration which must + * match this module's version. + * @property {CreatorConfig[]} creators - an array of creators. + */ + + /** + * @type {CreatorConfig} + * @property {string} [id] - a unique id for the creator. optional, for + * debuggin purposes. + * @property {function|string} is_applicable - If this is a string this has + * to be valid javascript! An asynchronous function which accepts one + * parameter, an entity in html representation, and which returns true + * iff this creator is applicable for the given entity. + * @property {string} create - This has to be valid javascript! An + * asynchronous function which accepts one parameter, an entity in html + * representation. It returns a HTMLElement or text node which will be + * shown in the bottom line container iff the creator is applicable. + */ + + /** + * Check if an entity has a path attribute and one of a set of extensions. + * + * Note: the array of extensions must contain only lower-case strings. + * + * @param {HTMLElement} entity + * @param {string[]} extensions - an array of file extesions, e.g. `jpg`. + * @return {boolean} true iff the entity has a path with one of the + * extensionss. + */ + const _path_has_file_extension = function (entity, extensions) { + const path = getEntityPath(entity); + if (path) { + for (var ext of extensions) { + if(path.toLowerCase().endsWith(ext)) { + return true; + } + } + } + return false; + } + + /** + * Create a preview for videos. + * + * @param {HTMLElement} entity + * @return {HTMLElement} a VIDEO element. + */ + const _create_video_preview = function (entity) { + var path = connection.getFileSystemPath() + getEntityPath(entity); + return $(`<video controls="controls"><source src="${path}"/></video>`)[0]; + } + + /** + * Create a preview for pictures. + * + * @param {HTMLElement} entity + * @return {HTMLElement} an IMG element. + */ + const _create_picture_preview = function (entity) { + var path = connection.getFileSystemPath() + getEntityPath(entity); + return $(`<img class="entity-image-preview" style="max-width: 200px; max-height=140px;" src="${path}"/>`)[0]; + } + + var fallback_preview = undefined; + + /** + * Default creators. + * + * @member {Creator[]} + */ + const _default_creators = [ + { // pictures + id: "_default_creators.pictures", + is_applicable: (entity) => _path_has_file_extension( + entity, ["jpg", "png", "gif", "svg"]), + create: _create_picture_preview + }, + { // videos + id: "_default_creators.videos", + is_applicable: (entity) => _path_has_file_extension( + entity, ["mp4", "mov", "webm"]), + create: _create_video_preview, + }, + { // fallback + id: "_default_creators.fallback", + is_applicable: (entity) => true, + create: (entity) => fallback_preview, + }, + + ]; + + + const previewShownEvent = new Event("ext_bottom_line.preview.shown"); + const previewReadyEvent = new Event("ext_bottom_line.preview.ready"); + + const _css_class_preview_container = "caosdb-f-ext_bottom_line-container"; + const _css_class_preview_container_resolvable = "caosdb-f-ext_bottom_line-container-resolvable"; + const _css_class_preview_container_button = "caosdb-f-ext_bottom_line-container-button"; + + /** + * Store the list of creators. + * + * @member {Creator[]} + */ + const _creators = []; + + /** + * Return the preview container of the entity. + * + * @param {HTMLElement} entity - An entity in HTML representation. + * @returns {HTMLElement} the preview container or `undefined` if the entity + * doesn't have any. + */ + const get_preview_container = function (entity) { + return $(entity).children(`.${_css_class_preview_container}`)[0]; + } + + /** + * Add the element to the entity's preview container and remove all other + * content. + * + * This method appends the element (usually a preview, a waiting + * notification or an error message) to the HTMLElement with class + * `caosdb-f-ext_bottom_line-container` which was added to the entity by the + * {@link root_preview_handler}. + * + * If the preview container cannot be found an error is logged, but not + * thrown. + * + * @param {HTMLElement|String} element - A preview, a waiting notification, + * an error message or similar. + * @param {HTMLElement} entity - An entity in HTML Representation which + * must have a (deep) child with class `caosdb-f-ext_bottom_line-container`. + */ + const set_preview_container = function (entity, element) { + const preview_container = $(get_preview_container(entity)); + if (preview_container[0]) { + preview_container.empty(); + var buttons = preview_container.siblings(`.${_css_class_preview_container_button}`); + if (element) { + buttons.css({"visibility": "initial"}); + preview_container.append(element); + } else { + buttons.css({"visibility": "hidden"}); + } + } else { + logger.error(new Error("Could not find the preview container.")); + } + } + + /** + * Append a preview to the entity and removes any pre-existing preview. + * + * If the preview is Promise for a preview a waiting notification is added + * to the entity instead and the actual preview is added after the Promise + * is resolved. If the Promise is rejected, a correspondig error is shown + * instead. + * + * @see root_preview_handler + * + * @async + * @param {HTMLElement} entity + * @param {string|HTMLElement|Promise} preview - A preview for an entity or + * a Promise for a preview (which resolves as a string or an HTMLElement as well). + */ + var set_preview = async function (entity, preview) { + try { + const wait = "Please wait..."; + set_preview_container(entity, wait); + const result = await preview; + set_preview_container(entity, result); + if (result) { + entity.dispatchEvent(previewReadyEvent); + } + } catch (err) { + logger.error(err); + const err_msg = "An error occured while loading this preview"; + set_preview_container(entity, err_msg); + } + } + + /** + * Create and return a preview for a given entity. + * + * This root_preview_creator iterates over all the registered creators and + * uses the first match, i.e. the first creator object which return true + * for the `is_applicable(entity)` method of the creator object. + * + * If a creator throws an error during checking whether it `is_applicable` + * or during the `create` the error is logged and the creator is treated as + * if it were not applicable. + * + * @async + * @param {HTMLElement} entity - the entity for which the preview is to be + * created. + * @returns {String|HTMLElement|Promise} A preview which can be added to + * the entity DOM representation or a Promise for such a preview. + */ + var root_preview_creator = async function (entity) { + for (let c of _creators) { + try { + if (await c.is_applicable(entity)) { + return c.create(entity); + } + } catch (err) { + logger.error(err); + } + } + return undefined; + }; + + /** + * Add a preview container to the entity. + * + * The preview container is a HTMLElement with class {@link + * _css_class_preview_container}. It is intended to contain the preview + * after it has been created. + * + * The presence of a preview container is also the marker that an entity + * has been visited by the root_preview_handler yet and that a preview is + * loaded and added to the entity or on its way. + * + * Depending on the implementation the actual preview container might be + * wrapped into other elements which can be used for styling or other + * purposes. + * + * @param {HTMLElement} entity - An entity in HTML representation. + * @return {HTMLElement} the newly created container. + */ + var add_preview_container = function (entity) { + const button_show = $('<button class="btn btn-xs"><span class="glyphicon glyphicon-menu-down"/> Show Preview</button>') + .css({width: "100%"}) + .addClass(_css_class_preview_container_button); + const button_hide = $('<button class="btn btn-xs"><span class="glyphicon glyphicon-menu-up"/> Hide Preview</button>') + .css({width: "100%"}) + .addClass(_css_class_preview_container_button) + .hide(); + const style = { padding: "0px 10px" }; + const container = $(`<div class="collapse"/>`) + .addClass(_css_class_preview_container) + .addClass(_css_class_preview_container_resolvable) + .css(style); + const show = function() { + button_show.hide(); + button_hide.show(); + container.collapse("show"); + } + const hide = function() { + button_hide.hide(); + button_show.show(); + container.collapse("hide"); + } + button_show.click(show); + button_hide.click(hide); + container.on("shown.bs.collapse", () => { container[0].dispatchEvent(previewShownEvent); }); + $(entity).append(container); + $(entity).append(button_show); + $(entity).append(button_hide); + + return container[0]; + } + + /** + * Create a preview for the entity and append it to the entity. + * + * The root_preview_handler calls the root_preview_creator which returns a + * preview which is to be added to the entity. + * + * @async + * @param {HTMLElement} entity - the entity for which the preview is to + * created. + */ + var root_preview_handler = async function (entity) { + var container = $(get_preview_container(entity) || add_preview_container(entity)); + if (container.hasClass(_css_class_preview_container_resolvable)) { + container.removeClass(_css_class_preview_container_resolvable); + const preview = root_preview_creator(entity); + await set_preview(entity, preview); + } + } + + /** + * Trigger the root_preview_handler for all entities within the view port + * when the view port. + */ + var root_preview_handler_trigger = function () { + var entities = $(".caosdb-entity-panel,.caosdb-entity-preview"); + for (let entity of entities) { + + // TODO viewport + 1000 px for earlier loading + if (is_in_view_port(entity)) { + root_preview_handler(entity); + } + } + } + + /** + * Intialize the scroll watcher which listens on the scroll event of the + * window and triggers the preview loading with a delay after the last + * scroll event. + * + * @param {integer} delay - timeout in milliseconds after the last scroll + * event. After this timeout the trigger is called. + * @param {function} trigger - the callback which is called. + */ + var init_watcher = function (delay, trigger) { + var scroll_timeout = undefined; + $(window).scroll(() => { + if (!scroll_timeout) { + clearTimeout(scroll_timeout); + } + scroll_timeout = setTimeout(trigger, delay); + }); + + var preview_timeout = undefined; + window.addEventListener( + preview.previewReadyEvent.type, + () => { + if (!preview_timeout) { + clearTimeout(scroll_timeout); + } + scroll_timeout = setTimeout(trigger, 100); + return true; + }, + true); + }; + + + /** + * Configure the creators. + * + * @param {BottomLineConfig} config + */ + var configure = async function (config) { + logger.debug("configure", config); + if(config.version != "0.1") { + throw new Error("Wrong version in config."); + } + + fallback_preview = config.fallback || fallback_preview; + + // append/load creators + _creators.splice(0, _creators.length); + for (let c of config.creators) { + _creators.push({ + id: c.id, + is_applicable: typeof c.is_applicable === "function" ? c.is_applicable : eval(c.is_applicable), + create: typeof c.create === "function" ? c.create : eval(c.create) + }); + } + + // append default creators + for (let c of _default_creators) { + _creators.push(c); + } + }; + + + /** + * Initialize this module. + * + * I.e. configure the list of creators and setup the scroll listener which + * triggers the root_preview_handler. + * + * @property {BottomLineConfig} [config] - an optional config. Per default, the + * configuration is fetched from the server. + */ + const init = async function (config) { + logger.info("ext_bottom_line initialized"); + + try { + let _config = config || await load_config("json/ext_bottom_line.json"); + await configure(_config); + + init_watcher(_config.delay || 500, root_preview_handler_trigger); + + // trigger the whole thing for the first time + root_preview_handler_trigger(); + + } catch (err) { + logger.error(err); + } + + } + + return { + previewShownEvent: previewShownEvent, + previewReadyEvent: previewReadyEvent, + init: init, + init_watcher: init_watcher, + configure: configure, + add_preview_container: add_preview_container, + root_preview_handler: root_preview_handler, + _creators: _creators, + _css_class_preview_container, + _css_class_preview_container_button, + _css_class_preview_container_resolvable, + } +}($, log.getLogger("ext_bottom_line"), resolve_references.is_in_viewport_vertically, load_config, getEntityPath, connection); + + +/** + * Helper for plotly + */ +var plotly_preview = function (logger, ext_bottom_line, plotly) { + + /** + * Create a plot with plotly. + * + * The layout and any other plotly options are set by this function. The + * only parameter `data` is the `data` parameter of the `Plotly.newPlot` + * factory. + * + * Hence the full documentation of the `data` parameter is available at + * https://plotly.com/javascript/plotlyjs-function-reference/#plotlynewplot + * + * + * @param {object[]} data - array of objects containing the data which is + * to be plotted. + * @returns {HTMLElement} the element which contains the plot. + */ + const create_plot = function (data) { + var div = $('<div/>')[0]; + plotly.newPlot(div, data, { margin: { t: 0}, height: 400, widht: 400 }, {responsive: true}); + return div; + } + + const resize_plots_event_handler = function (e) { + var plots = $(e.target).find(".js-plotly-plot"); + for (let plot of plots) { + plotly.Plots.resize(plot); + } + } + + const init = function () { + window.addEventListener(ext_bottom_line.previewReadyEvent.type, + resize_plots_event_handler, true); + window.addEventListener(ext_bottom_line.previewShownEvent.type, + resize_plots_event_handler, true); + } + + return { + create_plot: create_plot, + init: init, + }; + +}(log.getLogger("plotly_preview"), ext_bottom_line, Plotly); + + +// this will be replaced by require.js in the future. +$(document).ready(function () { + if ("${BUILD_MODULE_EXT_BOTTOM_LINE}" === "ENABLED") { + caosdb_modules.register(plotly_preview); + caosdb_modules.register(ext_bottom_line); + } +}); diff --git a/src/core/js/ext_revisions.js b/src/core/js/ext_revisions.js new file mode 100644 index 0000000000000000000000000000000000000000..3ee086e60ed34ac659c5bb92de71d78b38f30e2b --- /dev/null +++ b/src/core/js/ext_revisions.js @@ -0,0 +1,229 @@ +/* + * ** header v3.0 + * This file is a part of the CaosDB Project. + * + * Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> + * Copyright (C) 2020 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 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. + */ + 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 + const obsolete = await transaction.retrieveEntityById(id); + $(obsolete).attr("id", "-1"); + $(obsolete).find("Permissions").remove(); + $(obsolete).find("Parent").remove(); + $(obsolete).append(`<Parent name="${_datamodel.obsolete}"/>`); + + const doc = _createDocument("Request"); + doc.firstElementChild.appendChild(obsolete); + const result = await transaction.insertEntitiesXml(doc); + const obsolete_id = $(result.firstElementChild).find("[id]").first().attr("id"); + logger.trace("leave _insert_obsolete", obsolete_id); + return obsolete_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.trace("enter _make_revision_of_property", obsolete_id); + const ret = (await transformation.transformProperty(str2xml(`<Response><Property id="${_datamodel._revisionOfId}" name="${_datamodel.revisionOf}" datatype="${_datamodel.obsolete}"></Property></Response>`))).firstElementChild; + + $(ret).find(".caosdb-f-property-value").append(`<div class="caosdb-property-edit-value"><select><option value="${obsolete_id}" selected="selected"></option></select></div>`); + + logger.trace("leave _make_revision_of_property", ret); + 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, + _logger: logger, + } +}($, 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/js/preview.js b/src/core/js/preview.js index a81b063e5c4b2e51f53335e7b86f43076ca8a0cb..04a7b699b2d1adae5143f7362e965828c3d61531 100644 --- a/src/core/js/preview.js +++ b/src/core/js/preview.js @@ -3,7 +3,9 @@ */ var preview = new function() { - this.previewReadyEvent = new Event("caosdb.preview.ready") + this.previewReadyEvent = new Event("caosdb.preview.ready"); + this.showPreviewEvent = new Event("caosdb.preview.show"); + this.hidePreviewEvent = new Event("caosdb.preview.hide"); this.carouselId = 0; this.classNameEntityPreview = "caosdb-entity-preview"; @@ -99,6 +101,7 @@ var preview = new function() { $(hidePreviewButton).hide(); $(refLinksContainer).show(); $(preview.getPreviewCarousel(ref_property_elem)).hide(); + ref_property_elem.dispatchEvent(preview.hidePreviewEvent); }); }; app.onLeaveShowLinks = function(e) { @@ -135,6 +138,7 @@ var preview = new function() { $(preview.getPreviewCarousel(ref_property_elem)).show(); $(hidePreviewButton).show(); $(refLinksContainer).hide(); + ref_property_elem.dispatchEvent(preview.showPreviewEvent); }); } app.onResetApp = function(e, error) { diff --git a/src/core/js/webcaosdb.js b/src/core/js/webcaosdb.js index 56f00429e63b8454a88c952e39bb5666a57ca583..87b9e744c5e9d876347e0764c567df1a02f978bd 100644 --- a/src/core/js/webcaosdb.js +++ b/src/core/js/webcaosdb.js @@ -289,6 +289,13 @@ this.connection = new function() { return base; } + /** + * Return the root path of the file system resource. + */ + this.getFileSystemPath = function () { + return connection.getBasePath() + "FileSystem/"; + } + /** * Return a full URI for these entities. * diff --git a/src/core/xsl/entity.xsl b/src/core/xsl/entity.xsl index 2a4a803f95dd22f942d1f2d7ea9ee4b3303bf53a..7187c6b7d183b340a8eaee4cbdcead7ada44d433 100644 --- a/src/core/xsl/entity.xsl +++ b/src/core/xsl/entity.xsl @@ -181,15 +181,6 @@ <xsl:apply-templates mode="entity-body" select="Property"/> </xsl:if> </ul> - <!-- Thumbnail --> - <xsl:if test="@path"> - <xsl:call-template name="entity-body-thumbnail"> - <xsl:with-param name="path" select="@path"/> - </xsl:call-template> - <xsl:call-template name="entity-body-video"> - <xsl:with-param name="path" select="@path"/> - </xsl:call-template> - </xsl:if> </div> <!-- Annotations --> <xsl:call-template name="annotation-section"> @@ -198,38 +189,6 @@ </xsl:call-template> </div> </xsl:template> - <!-- Thumbnails of images --> - <xsl:template name="entity-body-thumbnail"> - <xsl:param name="path"/> - <xsl:if test="contains('.jpg.gif.png.svg',translate(substring($path, string-length($path) - 3), 'JPGIFNSV', 'jpgifnsv'))"> - <div class="row"> - <div class="col-sm-12"> - <img class="entity-image-preview" style="max-width: 200px; max-height: 140px;"> - <xsl:attribute name="src"> - <xsl:value-of select="concat($filesystempath,$path)"/> - </xsl:attribute> - </img> - </div> - </div> - </xsl:if> - </xsl:template> - <!-- Video display --> - <xsl:template name="entity-body-video"> - <xsl:param name="path"/> - <xsl:if test="contains('.mp4.mov.webm',translate(substring($path, string-length($path) - 3), 'MPOVWEB', 'mpovweb'))"> - <div class="row"> - <div class="col-sm-12"> - <video controls="controls"> - <source> - <xsl:attribute name="src"> - <xsl:value-of select="concat($filesystempath,$path)"/> - </xsl:attribute> - </source> - </video> - </div> - </div> - </xsl:if> - </xsl:template> <!-- PROPERTIES --> <xsl:template match="Property" mode="entity-body"> <li class="list-group-item caosdb-v-property-row caosdb-f-entity-property"> diff --git a/src/core/xsl/filesystem.xsl b/src/core/xsl/filesystem.xsl index 1d9c189a029bf2459d67bd5cb0069f1fceb3149f..93028ed2f9667f5f7b673483cb87dacb2591d5ac 100644 --- a/src/core/xsl/filesystem.xsl +++ b/src/core/xsl/filesystem.xsl @@ -48,6 +48,21 @@ </xsl:choose> </xsl:if> </xsl:template> + <!-- Thumbnails of images (Deprecated)--> + <xsl:template name="entity-body-thumbnail"> + <xsl:param name="path"/> + <xsl:if test="contains('.jpg.gif.png.svg',translate(substring($path, string-length($path) - 3), 'JPGIFNSV', 'jpgifnsv'))"> + <div class="row"> + <div class="col-sm-12"> + <img class="entity-image-preview" style="max-width: 200px; max-height: 140px;"> + <xsl:attribute name="src"> + <xsl:value-of select="concat($filesystempath,$path)"/> + </xsl:attribute> + </img> + </div> + </div> + </xsl:if> + </xsl:template> <xsl:template match="dir" mode="filesystem-item"> <li class="list-group-item"> <a class="caosdb-fs-dir"> diff --git a/src/core/xsl/main.xsl b/src/core/xsl/main.xsl index 40d9b24a4235e87f3b546d33808ff9372289d2f1..f4a128f63f52488657b32e8ff03043c276d4aaa3 100644 --- a/src/core/xsl/main.xsl +++ b/src/core/xsl/main.xsl @@ -132,6 +132,11 @@ <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/loglevel.js')"/> </xsl:attribute> </xsl:element> + <xsl:element name="script"> + <xsl:attribute name="src"> + <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/plotly.js')"/> + </xsl:attribute> + </xsl:element> <xsl:element name="script"> <xsl:attribute name="src"> <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/webcaosdb.js')"/> @@ -225,6 +230,16 @@ <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/tour.js')"/> </xsl:attribute> </xsl:element> + <xsl:element name="script"> + <xsl:attribute name="src"> + <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 762262ae2d2f0dbfc32a78f1e7aa004cb6b9a439..0d97a415334fd441319eec7e4db262c34d64ef07 100644 --- a/test/core/index.html +++ b/test/core/index.html @@ -38,6 +38,7 @@ <script src="js/bootstrap-select.js"></script> <script src="js/bootstrap-autocomplete.min.js"></script> <script src="js/webcaosdb.js"></script> + <script src="js/plotly.js"></script> <script> caosdb_modules.auto_init = false; log.setLevel("trace"); @@ -63,6 +64,8 @@ <script src="js/proj4.js"></script> <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> @@ -79,6 +82,8 @@ <script src="js/modules/form_elements.js.js"></script> <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/entity.xsl.js b/test/core/js/modules/entity.xsl.js index a853c36556c5482e0bd0b1f751755e88b871d25e..9b790a007435d05d1e9410774bc8c898fe287a16 100644 --- a/test/core/js/modules/entity.xsl.js +++ b/test/core/js/modules/entity.xsl.js @@ -113,27 +113,6 @@ QUnit.test("Property with Permissions tag and type='ExperimentSeries' is recogni assert.equal(link_e.getAttribute("href"), "/entitypath/129950", "href location"); }); -QUnit.test("File path is not converted to lower case.", function(assert) { - let xml_str = '<File path="UPPERCASE.JPG" id="1234"></File>'; - let xml = str2xml(xml_str); - let html = applyTemplates(xml, this.entityXSL, 'entities'); - - // inject an entrance rule - //var xsl_tmp = injectTemplate(this.entityXSL, '<xsl:template match="/"><xsl:apply-templates select="File" mode="top-level-data"/></xsl:template>'); - - - //var params = { - // entitypath: "/entitypath/", - // filesystempath: "/filesystempath/" - //}; - //var html = xslt(xml, xsl, params); - assert.ok(html, "html is ok."); - - var link_e = html.firstElementChild.getElementsByTagName("IMG")[0] - assert.ok(link_e, "<img> tag is there."); - assert.equal(link_e.getAttribute("src"), "/filesystempath/UPPERCASE.JPG", "src location is UPPERCASE.JPG"); -}); - QUnit.test("back references", function(assert) { // inject an entrance rule var xsl = injectTemplate(this.entityXSL, '<xsl:template match="/"><xsl:apply-templates select="*/@id" mode="backreference-link"/></xsl:template>'); diff --git a/test/core/js/modules/ext_bottom_line.js b/test/core/js/modules/ext_bottom_line.js new file mode 100644 index 0000000000000000000000000000000000000000..46903ead3226c1cb6038e9320548284ce76b0672 --- /dev/null +++ b/test/core/js/modules/ext_bottom_line.js @@ -0,0 +1,124 @@ +/* + * ** 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_bottom_line_test_suite = function ($, ext_bottom_line, QUnit) { + + const sleep = (ms) => { + return new Promise(res => setTimeout(res, ms)) + } + + var test_config = { "version": 0.1, + "fallback": "blablabla", + "creators": [ + { "id": "test.success", + "is_applicable": "(entity) => getParents(entity).map(par => par.name).includes('TestPreviewRecordType') && getEntityName(entity) === 'TestPreviewRecord-success'", + "create": "(entity) => 'SUCCESS'" + }, + { "id": "test.error", + "is_applicable": "(entity) => getParents(entity).map(par => par.name).includes('TestPreviewRecordType') && getEntityName(entity) === 'TestPreviewRecord-error'", + "create": "(entity) => new Promise((res,rej) => {rej('Test Error');})" + }, + { "id": "test.load-forever", + "is_applicable": "(entity) => getParents(entity).map(par => par.name).includes('TestPreviewRecordType') && getEntityName(entity) === 'TestPreviewRecord-load-forever'", + "create": "(entity) => new Promise((res,rej) => {})" + }, + { "id": "test.success-2", + "is_applicable": "(entity) => getParents(entity).map(par => par.name).includes('TestPreviewRecordType') && getEntityName(entity) !== 'TestPreviewRecord-fall-back'", + "create": "(entity) => { return plotly_preview.create_plot([{x: [1,2,3,4,5], y: [1,2,4,8,16]}]); }" + } + ] + }; + + QUnit.module("ext_bottom_line.js", { + before: async function (assert) { + // setup before module + await ext_bottom_line.configure(test_config); + }, + beforeEach: function (assert) { + // setup before each test + }, + afterEach: function (assert) { + // teardown after each test + }, + after: function (assert) { + // teardown after module + } + }); + + QUnit.test("_creators", function (assert) { + assert.equal(ext_bottom_line._creators.length, 7, "seven creators"); + }); + + QUnit.test("add_preview_container", function(assert) { + var entity = $("<div/>"); + var container = $(ext_bottom_line.add_preview_container(entity[0])); + assert.ok(container.hasClass(ext_bottom_line._css_class_preview_container), `has class ${ext_bottom_line._css_class_preview_container}`); + assert.ok(container.hasClass(ext_bottom_line._css_class_preview_container_resolvable), `has class ${ext_bottom_line._css_class_preview_container_resolvable}`); + }); + + QUnit.test("root_preview_handler", async function(assert) { + for (let name_suffix of ["fall-back", "error", "load-forever", "success"]) { + let name = "TestPreviewRecord-" + name_suffix; + let entity_xml = `<Response><Record name="${name}"><Parent name='TestPreviewRecordType'/></Record></Response>`; + let entity = (await transformation.transformEntities(str2xml(entity_xml)))[0]; + assert.equal(getEntityName(entity), name, "correct name"); + var container = $(ext_bottom_line.add_preview_container(entity)); + + assert.ok(container.hasClass(ext_bottom_line._css_class_preview_container), `before has class ${ext_bottom_line._css_class_preview_container}`); + assert.ok(container.hasClass(ext_bottom_line._css_class_preview_container_resolvable), `before has class ${ext_bottom_line._css_class_preview_container_resolvable}`); + + if (name_suffix === "load-forever"){ + ext_bottom_line.root_preview_handler(entity); + await sleep(1000); + } else { + await ext_bottom_line.root_preview_handler(entity); + } + + // ..._resolvable class removed + assert.ok(container.hasClass(ext_bottom_line._css_class_preview_container), `after has class ${ext_bottom_line._css_class_preview_container}`); + assert.notOk(container.hasClass(ext_bottom_line._css_class_preview_container_resolvable), `after missing class ${ext_bottom_line._css_class_preview_container_resolvable}`); + + + switch(name_suffix) { + case "fall-back": + assert.equal(container.text(), "blablabla"); + break; + case "error": + assert.equal(container.text(), "An error occured while loading this preview"); + break; + case "load-forever": + assert.equal(container.text(), "Please wait..."); + break; + case "success": + assert.equal(container.text(), "SUCCESS"); + break; + default: + assert.ok(false); + } + + } + + }); + +}($, ext_bottom_line, QUnit); 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..e90fd7c97851e5f054690cb0d376be5e14d826e4 --- /dev/null +++ b/test/core/js/modules/ext_revisions.js.js @@ -0,0 +1,121 @@ +/* + * ** header v3.0 + * This file is a part of the CaosDB Project. + * + * Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> + * Copyright (C) 2020 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'; + +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; + ext_revisions._logger.setLevel("trace"); + }, + 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"; + console.log(xml2str(xml)); + 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);