From 4941105459dc4e5380b66f980a2daf41080cdb2f Mon Sep 17 00:00:00 2001
From: Timm Fitschen <t.fitschen@indiscale.com>
Date: Mon, 15 Jun 2020 22:11:37 +0000
Subject: [PATCH] EHN: Versioning Support

* The version information is displayed when the user clicks on a clock icon on the right side of the entity panel's header.
* The user is warned, when the displayed entity is not the latest version of that entity.
* The Edit Mode does not allow to edit old versions
* The Preview (click on the eye symbol) loads and shows the entities in the referenced version (single references and lists)
---
 .gitlab-ci.yml                       |  10 +-
 CHANGELOG.md                         |   5 +
 misc/versioning_test_data.py         |  93 ++++++++++++++++++
 src/core/css/webcaosdb.css           |  11 +++
 src/core/js/caosdb.js                |  29 ++++++
 src/core/js/edit_mode.js             |   8 ++
 src/core/js/preview.js               |  89 +++++++++++++-----
 src/core/xsl/entity.xsl              | 135 +++++++++++++++++++++++++--
 test/core/js/modules/caosdb.js.js    |  11 +++
 test/core/js/modules/entity.xsl.js   |  84 +++++++++++++++++
 test/core/js/modules/webcaosdb.js.js |  65 +++++++++----
 11 files changed, 485 insertions(+), 55 deletions(-)
 create mode 100755 misc/versioning_test_data.py

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 07f72610..548f8645 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -21,9 +21,10 @@
 # along with this program. If not, see <https://www.gnu.org/licenses/>.
 
 variables:
-   CI_REGISTRY_IMAGE: $CI_REGISTRY/caosdb/caosdb-webui/testenv
-   # When using dind, it's wise to use the overlayfs driver for
-   # improved performance.
+  DEPLOY_REF: dev
+  CI_REGISTRY_IMAGE: $CI_REGISTRY/caosdb/caosdb-webui/testenv
+  # When using dind, it's wise to use the overlayfs driver for
+  # improved performance.
 
 image: $CI_REGISTRY_IMAGE:latest
 
@@ -66,10 +67,11 @@ trigger_build:
     - echo $TOKEN
     - /usr/bin/curl -X POST
        -F token=$DEPLOY_TRIGGER_TOKEN
+       -F "variables[F_BRANCH]=$CI_COMMIT_REF_NAME"
        -F "variables[WEBUI]=$CI_COMMIT_REF_NAME"
        -F "variables[TriggerdBy]=WEBUI"
        -F "variables[TriggerdByHash]=$CI_COMMIT_SHORT_SHA"
-       -F ref=dev https://gitlab.indiscale.com/api/v4/projects/14/trigger/pipeline
+       -F ref=$DEPLOY_REF https://gitlab.indiscale.com/api/v4/projects/14/trigger/pipeline
 
 # Build a docker image in which tests for this repository can run
 build-testenv:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 645616eb..323abc3f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### Added (for new features, dependecies etc.)
 
+* basic support for entity versioning:
+    * entity.xsl generates a versioning button which opens a versioning info modal.
+    * `preview` also works for versioned references
+    * `edit_mode` prevents the user from editing old versions of an entity.
+
 ### Changed (for changes in existing functionality)
 - added a layout argument to the create_plot function of ext_bottom_line
 
diff --git a/misc/versioning_test_data.py b/misc/versioning_test_data.py
new file mode 100755
index 00000000..eaa83e46
--- /dev/null
+++ b/misc/versioning_test_data.py
@@ -0,0 +1,93 @@
+#!/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
+
+""" Insert test data for manually testing the versioning feature in the
+webinterface.
+"""
+# pylint: disable=no-member
+
+import caosdb
+
+# clean
+old = caosdb.execute_query("FIND Test*")
+if len(old) > 0:
+    old.delete()
+
+# data model
+
+rt = caosdb.RecordType("TestRT")
+rt.insert()
+
+
+# test data
+## record with several versions
+rec1 = caosdb.Record("TestRecord1-firstVersion").add_parent("TestRT")
+rec1.description = "This is the first version."
+rec1.insert()
+
+rec1.name = "TestRecord1-secondVersion"
+rec1.description = "This is the second version."
+rec1.update()
+if rec1.version:
+    ref = str(rec1.id) + "@" + rec1.version.id
+else:
+    ref = rec1.id
+
+rec1.name = "TestRecord1-thirdVersion"
+rec1.description = "This is the third version."
+rec1.update()
+
+rec2 = caosdb.Record("TestRecord2").add_parent("TestRT")
+rec2.description = ("This record references the TestRecord1 in the second "
+                    "version where the name and the description of the record "
+                    "should indicate the referenced version.")
+rec2.add_property("TestRT", ref)
+rec2.insert()
+
+rec3 = caosdb.Record("TestRecord3").add_parent("TestRT")
+rec3.description = ("This record references the TestRecord1 without "
+                    "specifying a version. Therefore the latest (at least the "
+                    "third version) is being openend when you click on the "
+                    "reference link.")
+rec3.add_property("TestRT", rec1.id)
+rec3.insert()
+
+rec4 = caosdb.Record("TestRecord4").add_parent("TestRT")
+rec4.description = ("This record has a list of references to several versions "
+                    "of TestRecord1. The first references the record without "
+                    "specifying the version, the other each reference a "
+                    "different version of that record.")
+if rec1.version:
+    rec4.add_property("TestRT", datatype=caosdb.LIST("TestRT"),
+                      value=[rec1.id,
+                             str(rec1.id) + "@HEAD",
+                             str(rec1.id) + "@HEAD~1",
+                             str(rec1.id) + "@HEAD~2"])
+else:
+    rec4.add_property("TestRT", datatype=caosdb.LIST("TestRT"),
+                      value=[rec1.id,
+                             str(rec1.id),
+                             str(rec1.id),
+                             str(rec1.id)])
+rec4.insert()
diff --git a/src/core/css/webcaosdb.css b/src/core/css/webcaosdb.css
index a7a17cd1..ea7ca2a8 100644
--- a/src/core/css/webcaosdb.css
+++ b/src/core/css/webcaosdb.css
@@ -27,6 +27,17 @@ body {
     flex-direction: column;
 }
 
+button.caosdb-v-entity-version-button {
+    height: 15px;
+}
+
+.caosdb-v-entity-header-buttons-list > * {
+    margin: 0;
+    margin-left: 8px;
+    padding: 0;
+    vertical-align: middle;
+}
+
 .caosdb-v-main-col {
     flex-grow: 1;
     max-width: 90vw;
diff --git a/src/core/js/caosdb.js b/src/core/js/caosdb.js
index ac2b1c75..1c1318fe 100644
--- a/src/core/js/caosdb.js
+++ b/src/core/js/caosdb.js
@@ -245,6 +245,35 @@ function getEntityID(element) {
     throw new Error("id is ambigous for this element!");
 }
 
+
+/**
+ * Get the version string of an entity.
+ *
+ * @param {HTMLElement} an Entity in HTML representation
+ *     (div.caosdb-entity-panel)
+ * @return {string} the version
+ */
+var getEntityVersion = function (entity) {
+    return entity.getAttribute("data-version-id");
+}
+
+
+/**
+ * Get the id and, if present, the version of an entity.
+ *
+ * @param {HTMLElement} an Entity in HTML representation
+ *     (div.caosdb-entity-panel)
+ * @return {string} <id>[@<version>]
+ */
+var getEntityIdVersion = function (entity) {
+    const id = getEntityID(entity);
+    const version = getEntityVersion(entity);
+    if (version) {
+        return `${id}@${version}`;
+    }
+    return id;
+}
+
 /**
  * Take a date and a time and format it into a CaosDB compatible representation.
  * @param date A date
diff --git a/src/core/js/edit_mode.js b/src/core/js/edit_mode.js
index b68d8ca4..cc935bd3 100644
--- a/src/core/js/edit_mode.js
+++ b/src/core/js/edit_mode.js
@@ -1168,6 +1168,10 @@ var edit_mode = new function() {
             const state = app.state;
             $('.caosdb-entity-panel').each(function(index) {
                 let entity = this;
+                if ($(entity).is("[data-version-successor]")) {
+                    // not the latest version -> ignore
+                    return;
+                }
 
                 // remove listeners from all entities
                 edit_mode.unset_entity_dropable(entity, edit_mode.dragover, edit_mode.dragleave, edit_mode.parent_drop_listener, edit_mode.property_drop_listener);
@@ -1202,6 +1206,10 @@ var edit_mode = new function() {
                 edit_mode.enable_new_buttons();
                 $('.caosdb-entity-panel').each(function(index) {
                     let entity = this;
+                    if ($(entity).is("[data-version-successor]")) {
+                        // not the latest version -> ignore
+                        return;
+                    }
                     if (typeof getEntityID(entity) == "undefined" || getEntityID(entity) == '') {
                         // no id -> insert
                         edit_mode.init_actions_panels(entity);
diff --git a/src/core/js/preview.js b/src/core/js/preview.js
index 9af9bb03..f74fd13f 100644
--- a/src/core/js/preview.js
+++ b/src/core/js/preview.js
@@ -30,9 +30,25 @@ var preview = new function() {
         return props;
     }
 
+    // import from global name space.
+    this.getEntityID = getEntityID;
+    this.getEntityVersion = getEntityVersion;
+    this.getEntityIdVersion = getEntityIdVersion;
+
+    /**
+     * Get the id and, if present, the version of an entity from a link or a
+     * displayed reference.
+     *
+     * @param {HTMLElement} a link to an entity.
+     * @return {string} <id>[@<version>]
+     */
+    this.getEntityRef = function (link) {
+        return link.getElementsByClassName("caosdb-id")[0].textContent;
+    }
+
     /**
      * Initialize the preview feature for all reference properties which belong to certain entity.
-     *  
+     *
      * @param {HTMLElement} entity
      * @return {HTMLElement[]} The initialized properties. 
      */
@@ -113,7 +129,7 @@ var preview = new function() {
         app.onEnterWaiting = function(e) {
             executeFailSave(function() {
                 preview.addWaitingNotification(ref_property_elem, preview.createWaitingNotification());
-                let entityIds = preview.getEntityIds(refLinksContainer);
+                let entityIds = preview.getAllEntityRefs(refLinksContainer);
                 preview.retrievePreviewEntities(entityIds).then(entities => {
                     app.receivePreview(entities);
                 }, err => {
@@ -391,9 +407,9 @@ var preview = new function() {
         let selectorButtons = preview.getSelectorButtons(nav);
         selectorButtons.each((index, button) => {
             let slide_id = button.getAttribute("data-slide-to");
-            let entity_id = getEntityId(button);
-            let entity = preview.getEntityById(entities, entity_id);
-            if (entity == null) throw new Error("Entity with ID " + entity_id + " could not be found!");
+            let entity_id_version = preview.getEntityRef(button);
+            let entity = preview.getEntityByIdVersion(entities, entity_id_version);
+            if (entity == null) throw new Error("Entity with ID " + entity_id_version + " could not be found!");
             inner.children[slide_id].appendChild(preview.preparePreviewEntity(entity));
         });
 
@@ -443,8 +459,8 @@ var preview = new function() {
      *
      */
     this.createSinglePreview = function(entities, refLinksContainer) {
-        let entityId = getEntityId(preview.getReferenceLinks(refLinksContainer)[0]);
-        let entity = preview.preparePreviewEntity(preview.getEntityById(entities, entityId));
+        const entityRef = preview.getEntityRef(preview.getReferenceLinks(refLinksContainer)[0]);
+        const entity = preview.preparePreviewEntity(preview.getEntityByIdVersion(entities, entityRef));
         return entity;
     }
 
@@ -456,15 +472,25 @@ var preview = new function() {
      * @return {HTMLElement} The prepared entity.
      */
     this.preparePreviewEntity = function(entity) {
+        // move version modal into body because otherwise it would be displayed
+        // inside the caroussel. That would make sense but there is simply not
+        // enough space.
+        $(entity).find(".caosdb-f-entity-version-info").appendTo(document.body);
+
+
+        // make backref button smaller
+        $(entity).find(".caosdb-backref-link > .hidden-xs").hide();
+
         var preparedEntity = entity.cloneNode(true);
 
         // header is clickable:
-        let href = connection.getBasePath() + transaction.generateEntitiesUri([getEntityId(entity)]);
+        let href = connection.getBasePath() + transaction.generateEntitiesUri([preview.getEntityRef(entity)]);
         let link = $('<a title="Load this entity in a new window." href="' + href + '" class="label caosdb-id caosdb-id-button" target="_blank"></a>');
         let entityIdElem = $(preparedEntity).find('.label.caosdb-id');
         link.insertAfter(entityIdElem);
         link.append(entityIdElem.text() + " ");
         link.append('<span class="glyphicon glyphicon-new-window"/>');
+        // TODO this link is not visible due to webcaosdb.css (caosdb-id)
         entityIdElem.remove();
 
         return preparedEntity;
@@ -529,22 +555,34 @@ var preview = new function() {
     }
 
     /**
-     * Get the entity with a certain ID from an array of entities. Returns null if no such entity
-     * is in the array.
+     * Get the entity with a certain ID and Version (if applicable) from an
+     * array of entities. Returns null if no such entity is in the array.
+     *
      * @param {HTMLElement[]} entities
-     * @param {Number} entity_id
-     * @return {HTMLElement} The entity with id=entity_id or null.
+     * @param {String} entity_id_version
+     * @return {HTMLElement} Matching entity or null.
      */
-    this.getEntityById = function(entities, entity_id) {
+    this.getEntityByIdVersion = function(entities, entity_id_version) {
         if (entities == null) {
             throw new Error("entities must not be null");
         }
-        if (entity_id == null || isNaN(entity_id)) {
-            throw new Error("entity_id is to be a number");
+        if (entity_id_version == null) {
+            throw new Error("entity_id_version must not be null");
+        }
+
+        // if the entity_id_version contains an "@" it is actually a reference
+        // to a versioned entity. Otherwise, it is just an id an thus only the
+        // id has to be matched.
+        const is_versioned = entity_id_version.indexOf("@") !== -1;
+        var matches;
+        if (is_versioned) {
+            matches = (e) => preview.getEntityIdVersion(e) === entity_id_version;
+        } else {
+            matches = (e) => preview.getEntityID(e) === entity_id_version;
         }
         for (let i = 0; i < entities.length; i++) {
-            let e = entities[i];
-            if (getEntityId(e) === entity_id) {
+            const e = entities[i];
+            if (matches(e)) {
                 return e;
             }
         }
@@ -694,26 +732,27 @@ var preview = new function() {
      * @param {HTMLElement} refLinksContainer
      * @return {String[]} An array of entity ids.
      */
-    this.getEntityIds = function(refLinksContainer) {
+    this.getAllEntityRefs = function(refLinksContainer) {
         if (refLinksContainer == null) {
             throw new Error("parameter refLinksContainer must not be null.");
         }
 
-        let entityIds = [];
-        preview.getReferenceLinks(refLinksContainer).each((index, link) => {
-            entityIds.push(getEntityId(link));
-        });
-        return entityIds;
+        let entityRefs = [];
+        for (let link of preview.getReferenceLinks(refLinksContainer)) {
+            entityRefs.push(preview.getEntityRef(link));
+        };
+        return entityRefs;
     }
 
     /**
      * Get an array of all reference links.
      * 
      * @param {HTMLElement} refLinksContainer - The original reference links.
-     * @return {jQuery} A collection of links.
+     * @return {HTMLElement[]} A collection of links.
      */
     this.getReferenceLinks = function(refLinksContainer) {
-        return $(refLinksContainer).find('a').addBack('a').has('.caosdb-id');
+        return $(refLinksContainer)
+            .find('a').addBack('a').has('.caosdb-id').toArray();
     }
 };
 
diff --git a/src/core/xsl/entity.xsl b/src/core/xsl/entity.xsl
index c43b2e8b..816d826c 100644
--- a/src/core/xsl/entity.xsl
+++ b/src/core/xsl/entity.xsl
@@ -46,9 +46,9 @@
       <xsl:attribute name="href">
         <xsl:value-of select="concat($entitypath, '?P=0L10&amp;query=FIND+Entity+which+references+', current())"/>
       </xsl:attribute>
-      <span class="glyphicon glyphicon-share-alt flipped-horiz-icon"/> References
+      <span class="glyphicon glyphicon-share-alt flipped-horiz-icon"/>
+      <span class="hidden-xs"> References</span>
     </a>
-    <span class="spacer"/>
   </xsl:template>
   <!-- special entity properties like type, checksum, path... -->
   <xsl:template match="@datatype" mode="entity-heading-attributes-datatype">
@@ -87,11 +87,16 @@
     </p>
   </xsl:template>
   <xsl:template match="*" mode="entity-action-panel">
-    <div class="caosdb-entity-actions-panel text-right btn-group-xs"></div>
+    <div class="caosdb-entity-actions-panel text-right btn-group-xs">
+        <xsl:apply-templates select="Version/Successor" mode="entity-action-panel-version">
+          <xsl:with-param name="entityId" select="@id"/>
+        </xsl:apply-templates>
+    </div>
   </xsl:template>
   <!-- Main entry for ENTITIES -->
   <xsl:template match="Property|Record|RecordType|File" mode="entities">
     <div class="panel panel-default caosdb-entity-panel">
+      <xsl:apply-templates select="Version" mode="entity-version-marker"/>
       <xsl:attribute name="id">
         <xsl:value-of select="@id"/>
       </xsl:attribute>
@@ -138,17 +143,22 @@
             </h5>
           </div>
           <div class="col-sm-4 text-right">
-            <h5>
+            <h5 class="caosdb-v-entity-header-buttons-list">
               <!-- Button for expanding/collapsing the comments section-->
-              <span class="caosdb-clickable glyphicon glyphicon-comment" data-toggle="collapse" title="Comments" style="margin-right: 10px;">
+              <span class="caosdb-clickable glyphicon glyphicon-comment" data-toggle="collapse" title="Toggle the comments section at the bottom of this entity.">
                 <xsl:attribute name="data-target">
                   <xsl:value-of select="concat('#', 'comment_', $entityid)"/>
                 </xsl:attribute>
               </span>
+              <span>
               <xsl:apply-templates mode="backreference-link" select="@id"/>
+              </span>
               <span class="label caosdb-id caosdb-id-button hidden">
                 <xsl:value-of select="@id"/>
               </span>
+              <xsl:apply-templates mode="entity-heading-attributes-version" select="Version">
+                <xsl:with-param name="entityId" select="@id"/>
+              </xsl:apply-templates>
             </h5>
           </div>
         </div>
@@ -357,7 +367,7 @@
       <xsl:when test="contains(concat('&lt;',@datatype),'&lt;LIST&lt;')">
         <!-- list -->
         <xsl:choose>
-          <xsl:when test="translate(normalize-space(text()),'0123456789','')='' and not(contains('+LIST&lt;INTEGER>+LIST&lt;DOUBLE>+LIST&lt;TEXT>+LIST&lt;BOOLEAN>+LIST&lt;DATETIME>+',concat('+',@datatype,'+')))">
+          <xsl:when test="not(contains('+LIST&lt;INTEGER>+LIST&lt;DOUBLE>+LIST&lt;TEXT>+LIST&lt;BOOLEAN>+LIST&lt;DATETIME>+',concat('+',@datatype,'+')))">
             <xsl:apply-templates mode="property-reference-value-list" select="."/>
           </xsl:when>
           <xsl:otherwise>
@@ -372,7 +382,7 @@
             <xsl:value-of select="text()"/>
           </xsl:with-param>
           <xsl:with-param name="reference">
-            <xsl:value-of select="translate(normalize-space(text()),'0123456789','')='' and not(contains('+INTEGER+DOUBLE+TEXT+BOOLEAN+DATETIME+',concat('+',@datatype,'+')))"/>
+            <xsl:value-of select="not(contains('+INTEGER+DOUBLE+TEXT+BOOLEAN+DATETIME+',concat('+',@datatype,'+')))"/>
           </xsl:with-param>
           <xsl:with-param name="boolean">
             <xsl:value-of select="@datatype='BOOLEAN'"/>
@@ -458,4 +468,115 @@
       </li>
     </ul>
   </xsl:template>
+  <!--VERSIONING-->
+  <xsl:template match="Version" mode="entity-heading-attributes-version">
+    <xsl:param name="entityId"/>
+    <xsl:param name="versionModalId">version-modal-<xsl:value-of select="generate-id()"/></xsl:param>
+    <!-- the clock button which opens the window with the versioning info -->
+    <button title="Versioning Info" type="button" data-toggle="modal">
+      <xsl:attribute name="data-target">#<xsl:value-of select="$versionModalId"/></xsl:attribute>
+      <xsl:attribute name="class">
+        caosdb-f-entity-version-button caosdb-v-entity-version-button btn
+        <xsl:if test="Successor">
+          <!-- indicate old version by color and symbol -->
+          <xsl:value-of select="' text-danger'"/>
+        </xsl:if>
+      </xsl:attribute>
+      <span class="glyphicon glyphicon-time"/>
+    </button>
+    <!-- the following div.modal is the window that pops up when the user clicks on the clock button -->
+    <div class="caosdb-f-entity-version-info modal fade" tabindex="-1" role="dialog">
+      <xsl:attribute name="id"><xsl:value-of select="$versionModalId"/></xsl:attribute>
+      <div class="modal-dialog modal-lg" role="document">
+        <div class="modal-content text-left">
+          <div>
+            <xsl:attribute name="class">
+              modal-header
+              <xsl:if test="Successor">
+                <!-- indicate old version by color -->
+                <xsl:value-of select="' bg-danger'"/>
+              </xsl:if>
+            </xsl:attribute>
+            <button type="button" class="close" data-dismiss="modal" aria-label="Close" title="Close"><span aria-hidden="true">×</span></button>
+            <h4 class="modal-title">Version Info</h4>
+            <p class="caosdb-entity-heading-attr">
+              <em class="caosdb-entity-heading-attr-name">
+              This is
+              <xsl:if test="Successor"><b>not</b></xsl:if>
+              the latest version of this entity.
+              </em>
+            </p>
+          </div>
+          <div class="modal-body">
+            <xsl:apply-templates mode="entity-version-modal-head" select="Successor">
+              <xsl:with-param name="entityId" select="$entityId"/>
+            </xsl:apply-templates>
+            <xsl:apply-templates mode="entity-version-modal-successor" select="Successor">
+              <xsl:with-param name="entityId" select="$entityId"/>
+            </xsl:apply-templates>
+            <p class="caosdb-entity-heading-attr">
+              <em class="caosdb-entity-heading-attr-name">This version:</em>
+              <xsl:value-of select="@id"/> (<xsl:value-of select="@date"/>)
+            </p>
+            <xsl:apply-templates mode="entity-version-modal-predecessor" select="Predecessor">
+              <xsl:with-param name="entityId" select="$entityId"/>
+            </xsl:apply-templates>
+          </div>
+        </div>
+      </div>
+    </div>
+  </xsl:template>
+  <xsl:template match="Predecessor" mode="entity-version-modal-predecessor">
+    <!-- content of the versioning window -->
+    <xsl:param name="entityId"/>
+    <p class="caosdb-entity-heading-attr">
+      <em class="caosdb-entity-heading-attr-name">Previous version:</em>
+      <a><xsl:attribute name="href"><xsl:value-of select="$entityId"/>@<xsl:value-of select="@id"/></xsl:attribute>
+        <xsl:value-of select="@id"/> (<xsl:value-of select="@date"/>)
+      </a>
+    </p>
+  </xsl:template>
+  <xsl:template match="Successor" mode="entity-version-modal-head">
+    <!-- content of the versioning window -->
+    <xsl:param name="entityId"/>
+    <p class="caosdb-entity-heading-attr">
+      <em class="caosdb-entity-heading-attr-name">Newest version:</em>
+      <a><xsl:attribute name="href"><xsl:value-of select="$entityId"/>@HEAD</xsl:attribute>
+        <xsl:value-of select="$entityId"/>@HEAD
+      </a>
+    </p>
+  </xsl:template>
+  <xsl:template match="Successor" mode="entity-version-modal-successor">
+    <!-- content of the versioning window -->
+    <xsl:param name="entityId"/>
+    <p class="caosdb-entity-heading-attr">
+      <em class="caosdb-entity-heading-attr-name">Next version:</em>
+      <a><xsl:attribute name="href"><xsl:value-of select="$entityId"/>@<xsl:value-of select="@id"/></xsl:attribute>
+        <xsl:value-of select="@id"/> (<xsl:value-of select="@date"/>)
+      </a>
+    </p>
+  </xsl:template>
+  <xsl:template match="Version/Successor" mode="entity-action-panel-version">
+    <!-- clickable warning message in the entity actions panel when there exists a newer version -->
+    <xsl:param name="entityId"/>
+    <a class="caosdb-f-entity-version-old-warning alert-warning btn btn-link" title="Go to the latest version of this entity.">
+      <xsl:attribute name="href"><xsl:value-of select="$entityId"/>@HEAD</xsl:attribute>
+      <strong>Warning</strong> A newer version exists!
+    </a>
+  </xsl:template>
+  <xsl:template match="Version" mode="entity-version-marker">
+    <!-- content of the data-version-id attribute -->
+    <xsl:attribute name="data-version-id">
+        <xsl:value-of select="@id"/>
+    </xsl:attribute>
+    <xsl:apply-templates select="Successor" mode="entity-version-marker"/>
+  </xsl:template>
+  <xsl:template match="Successor" mode="entity-version-marker">
+    <!-- content of the data-version-successor attribute
+         This data-attribute marks entities which have a newer version.
+    -->
+    <xsl:attribute name="data-version-successor">
+      <xsl:value-of select="@id"/>
+    </xsl:attribute>
+  </xsl:template>
 </xsl:stylesheet>
diff --git a/test/core/js/modules/caosdb.js.js b/test/core/js/modules/caosdb.js.js
index 35a18340..250d0f75 100644
--- a/test/core/js/modules/caosdb.js.js
+++ b/test/core/js/modules/caosdb.js.js
@@ -167,6 +167,17 @@ QUnit.test("getProperties", function(assert) {
     assert.equal(ps[0].datatype, "TEXT");
 });
 
+QUnit.test("getEntityIdVersion", function(assert) {
+    // without version
+    var html = $('<div data-entity-id="1234"/>')[0];
+    assert.equal(getEntityIdVersion(html), "1234", "id extracted");
+
+    // with version
+    html = $('<div data-entity-id="1234" data-version-id="abcd"/>')[0];
+    assert.equal(getEntityIdVersion(html), "1234@abcd", "<id>@<version> extracted");
+
+});
+
 /**
   * @author Alexander Schlemmer
   * Test whether parents are retrieved correctly.
diff --git a/test/core/js/modules/entity.xsl.js b/test/core/js/modules/entity.xsl.js
index e9e50955..c607ee28 100644
--- a/test/core/js/modules/entity.xsl.js
+++ b/test/core/js/modules/entity.xsl.js
@@ -180,6 +180,90 @@ QUnit.test("single-value template with reference property.", function(assert) {
     assert.equal($(link).find('.caosdb-id').length, 1, 'has caosdb-id span');
 })
 
+QUnit.test("old version warning", function(assert) {
+    // with successor tag
+    var xmlstr = '<Record id="2345"><Version id="abcd1234"><Successor id="bcde2345"/></Version></Record>';
+    var xml = str2xml(xmlstr);
+    var html = applyTemplates(xml, this.entityXSL, "entities", "*");
+    assert.equal($(html).find(".caosdb-entity-panel .caosdb-f-entity-version-old-warning").length, 1, "warning present");
+
+    // with version tag, without successor
+    xmlstr = '<Record id="2345"><Version id="abcd1234"/></Record>';
+    xml = str2xml(xmlstr);
+    html = applyTemplates(xml, this.entityXSL, "entities", "*");
+    assert.equal($(html).find(".caosdb-f-entity-version-old-warning").length, 0, "warning not present");
+
+    // without version tag
+    xmlstr = '<Record id="2345"></Record>';
+    xml = str2xml(xmlstr);
+    html = applyTemplates(xml, this.entityXSL, "entities", "*");
+    assert.equal($(html).find(".caosdb-f-entity-version-old-warning").length, 0, "warning not present");
+});
+
+QUnit.test("version button", function(assert) {
+    // with version tag
+    var xmlstr = '<Record id="2345"><Version id="abcd1234"/></Record>';
+    var xml = str2xml(xmlstr);
+    var html = applyTemplates(xml, this.entityXSL, "entities", "*");
+
+    assert.equal($(html).find("div.caosdb-entity-panel button.caosdb-f-entity-version-button").length, 1, "button present");
+
+    // without version tag
+    xmlstr = '<Record id="2345"></Record>';
+    xml = str2xml(xmlstr);
+    html = applyTemplates(xml, this.entityXSL, "entities", "*");
+    assert.equal($(html).find(".caosdb-f-entity-version-button").length, 0, "button not present");
+});
+
+QUnit.test("version info modal", function(assert) {
+    // with version tag
+    var xmlstr = '<Record id="2345"><Version id="abcd1234"/></Record>';
+    var xml = str2xml(xmlstr);
+    var html = applyTemplates(xml, this.entityXSL, "entities", "*");
+
+    assert.equal($(html).find("div.caosdb-entity-panel div.caosdb-f-entity-version-info").length, 1, "info present");
+
+    // without version tag
+    xmlstr = '<Record id="2345"></Record>';
+    xml = str2xml(xmlstr);
+    html = applyTemplates(xml, this.entityXSL, "entities", "*");
+    assert.equal($(html).find(".caosdb-f-entity-version-info").length, 0, "info not present");
+});
+
+QUnit.test("data-version-id attribute", function(assert) {
+    // with version tag
+    var xmlstr = '<Record id="2345"><Version id="abcd1234"/></Record>';
+    var xml = str2xml(xmlstr);
+    var html = applyTemplates(xml, this.entityXSL, "entities", "*");
+    assert.equal($(html).find("div.caosdb-entity-panel[data-version-id='abcd1234']").length, 1, "data-version-id attribute present");
+
+    // without version tag
+    xmlstr = '<Record id="2345"></Record>';
+    xml = str2xml(xmlstr);
+    html = applyTemplates(xml, this.entityXSL, "entities", "*");
+    assert.equal($(html).find("div.caosdb-entity-panel[data-version-id]").length, 0, "data-version-id attribute not present");
+});
+
+QUnit.test("data-version-successor attribute", function(assert) {
+    // with successor tag
+    var xmlstr = '<Record id="2345"><Version id="abcd1234"><Successor id="bcde2345"/></Version></Record>';
+    var xml = str2xml(xmlstr);
+    var html = applyTemplates(xml, this.entityXSL, "entities", "*");
+    assert.equal($(html).find("div.caosdb-entity-panel[data-version-successor='bcde2345']").length, 1, "data-version-successor attribute present");
+
+    // with version tag, without successor
+    xmlstr = '<Record id="2345"><Version id="abcd1234"/></Record>';
+    xml = str2xml(xmlstr);
+    html = applyTemplates(xml, this.entityXSL, "entities", "*");
+    assert.equal($(html).find("div.caosdb-entity-panel[data-version-successor]").length, 0, "data-version-successor attribute not present");
+
+    // without version tag
+    xmlstr = '<Record id="2345"></Record>';
+    xml = str2xml(xmlstr);
+    html = applyTemplates(xml, this.entityXSL, "entities", "*");
+    assert.equal($(html).find("div.caosdb-entity-panel[data-version-successor]").length, 0, "data-version-successor attribute not present");
+});
+
 /* MISC FUNCTIONS */
 function applyTemplates(xml, xsl, mode, select = "*") {
     let entryRule = '<xsl:template priority="9" match="/"><xsl:apply-templates select="' + select + '" mode="' + mode + '"/></xsl:template>';
diff --git a/test/core/js/modules/webcaosdb.js.js b/test/core/js/modules/webcaosdb.js.js
index 3b615e11..1f8db45c 100644
--- a/test/core/js/modules/webcaosdb.js.js
+++ b/test/core/js/modules/webcaosdb.js.js
@@ -164,7 +164,6 @@ QUnit.test("get", function(assert) {
     });
 });
 
-
 /* MODULE transformation */
 QUnit.module("webcaosdb.js - transformation", {
     before: function(assert) {
@@ -782,34 +781,29 @@ QUnit.test("getActiveSlideItemIndex", function(assert) {
     assert.equal(2, preview.getActiveSlideItemIndex(okElem2));
 });
 
-QUnit.test("getEntityById", function(assert) {
-    assert.ok(preview.getEntityById, "function available");
+QUnit.test("getEntityByIdVersion", function(assert) {
+    assert.ok(preview.getEntityByIdVersion, "function available");
     let e1 = $('<div><div class="caosdb-id">1</div></div>')[0];
     let e2 = $('<div><div class="caosdb-id">2</div></div>')[0];
-    let e3 = $('<div><div class="caosdb-id">3</div><div><div class="caosdb-id">1</div></div></div>')[0];
 
-    let es = [e1, e2, e3];
+    let es = [e1, e2];
 
     assert.throws(() => {
-        preview.getEntityById()
+        preview.getEntityByIdVersion()
     }, "no param throws.");
     assert.throws(() => {
-        preview.getEntityById(null, 1)
+        preview.getEntityByIdVersion(null, 1)
     }, "null first param throws.");
     assert.throws(() => {
-        preview.getEntityById("asdf", 1)
+        preview.getEntityByIdVersion("asdf", 1)
     }, "string first param throws.");
     assert.throws(() => {
-        preview.getEntityById(es, null)
+        preview.getEntityByIdVersion(es, null)
     }, "null second param throws.");
-    assert.throws(() => {
-        preview.getEntityById(es, "asdf")
-    }, "string second param throws.");
 
-    assert.equal(e1, preview.getEntityById(es, 1), "find 1");
-    assert.equal(e2, preview.getEntityById(es, 2), "find 2");
-    assert.equal(e3, preview.getEntityById(es, 3), "find 3");
-    assert.equal(null, preview.getEntityById(es, 4), "find 4 -> null");
+    assert.equal(e1, preview.getEntityByIdVersion(es, "1"), "find 1");
+    assert.equal(e2, preview.getEntityByIdVersion(es, "2"), "find 2");
+    assert.equal(null, preview.getEntityByIdVersion(es, "3"), "find 3 -> null");
 });
 
 QUnit.test("createEmptyInner", function(assert) {
@@ -870,7 +864,7 @@ QUnit.test("createCarouselNav", function(assert) {
     let refLinks = $('<div class="caosdb-value-list"><a><span class="caosdb-id">1234</span></a><a><span class="caosdb-id">2345</span></a><a><span class="caosdb-id">3456</span></a><a><span class="caosdb-id">4567</span></a></div>')[0];
     let e1 = $('<div><div class="caosdb-id">1234</div></div>')[0];
     let e2 = $('<div><div class="caosdb-id">2345</div></div>')[0];
-    let e3 = $('<div><div class="caosdb-id">3456</div><div><div class="caosdb-id">1234</div></div></div>')[0];
+    let e3 = $('<div><div class="caosdb-id">3456</div></div>')[0];
     let e4 = $('<div><div class="caosdb-id">4567</div></div>')[0];
     let entities = [e1, e3, e4, e2];
     let carousel = preview.createPreviewCarousel(entities, refLinks);
@@ -1040,8 +1034,41 @@ QUnit.test("preparePreviewEntity", function(assert){
     assert.equal($(prepared).find('a.caosdb-id')[0].href, connection.getBasePath() + "Entity/1234", "link is correct.");
 });
 
-QUnit.test("getEntitiyIds", function(assert) {
-    assert.ok(preview.getEntityIds, 'function available');
+QUnit.test("getEntityRef", function(assert) {
+    assert.ok(preview.getEntityRef, 'function available');
+
+    var html = $('<div><div class="caosdb-id">sdfg</div></div>')[0];
+    assert.equal(preview.getEntityRef(html), "sdfg", "id extracted");
+
+    html = $('<div><div class="caosdb-id"></div></div>')[0];
+    assert.equal(preview.getEntityRef(html), "", "empty string extracted");
+
+    html = $('<div></div>')[0];
+    assert.throws(()=>{preview.getEntityRef(html);}, "missing .caosdb-id throws");
+});
+
+
+QUnit.test("getAllEntityRefs", function(assert) {
+    assert.ok(preview.getAllEntityRefs, 'function available');
+    assert.throws(preview.getAllEntityRefs, "null param throws");
+
+    // overwrite called methods
+    const oldGetReferenceLinks = preview.getReferenceLinks;
+    preview.getReferenceLinks = function(links) {
+        assert.propEqual(links, ["bla"], "array is passed to getReferenceLinks");
+        return links;
+    }
+    const oldGetEntityRef = preview.getEntityRef;
+    preview.getEntityRef = function(link) {
+        assert.equal(link, "bla", "array elements are passed to getEntityRef");
+        return "asdf";
+    }
+
+    assert.propEqual(preview.getAllEntityRefs(["bla"]), ["asdf"], "returns array with refs");
+
+
+    // cleanup
+    preview.getReferenceLinks = oldGetReferenceLinks;
 });
 
 QUnit.test("retrievePreviewEntities", function(assert) {
-- 
GitLab