diff --git a/.gitignore b/.gitignore index f69db87ad5a5226535559b6965e771d975ded103..d2bd7089a35ed5496464734e2026397e3a802baa 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ .* !/.git* +# backup files +*~ + # extracted libraries /libs/** !/libs/*.zip @@ -26,3 +29,4 @@ xerr.log conf/ext test/ext src/ext +*~ diff --git a/.gitlab/issue_templates/default.md b/.gitlab/issue_templates/default.md new file mode 100644 index 0000000000000000000000000000000000000000..aa1a65aca363b87aff50280e1a86824009d2098b --- /dev/null +++ b/.gitlab/issue_templates/default.md @@ -0,0 +1,28 @@ +## Summary + +*Please give a short summary of what the issue is.* + +## Expected Behavior + +*What did you expect how the software should behave?* + +## Actual Behavior + +*What did the software actually do?* + +## Steps to Reproduce the Problem + +*Please describe, step by step, how others can reproduce the problem. Please try these steps for yourself on a clean system.* + +1. +2. +3. + +## Specifications + +- Version: *Which version of this software?* +- Platform: *Which operating system, which other relevant software versions?* + +## Possible fixes + +*Do you have ideas how the issue can be resolved?* diff --git a/CHANGELOG.md b/CHANGELOG.md index aaace618e1ba063ee7ddd13148c96da71607f9f3..d3689e84e60ae5e3b37ac21425e486bff9a0e6cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,30 @@ # Changelog All notable changes to this project will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added (for new features, dependecies etc.) + +* Module `ext_qrcode` which generates a QR Code for an entity (pointing to the + the head or the exact version). + +### Changed (for changes in existing functionality) + +### Deprecated (for soon-to-be removed features) + +### Removed (for now removed features) + +* `getEntityId`, a former duplicate of `getEntityID` which must be used instead. + +### Fixed (for any bug fixes) + +### Security (in case of vulnerabilities) + +### Documentation (for notable additions or changes of the documentation) + ## [v0.4.0-rc1] - 2021-06-16 ### Added (for new features, dependecies etc.) @@ -11,12 +32,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ext_applicable` module for building fancy features which append functionality to entities (EXPERIMENTAL). - `ext_cosmetics` module which converts http(s) uris in property values into - clickable links. + clickable links (with tests) - Added a menu/toc for the tour - Added a previous and next buttons for pages in the tour - Added warnings to inform about minimum width when accessing tour and edit mode on small screens. - Added a tutorial for the edit mode to the documentation +- Documentation on how to customize reference resolving ### Changed (for changes in existing functionality) @@ -26,6 +48,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 dropped entirely (e.g. "jumbotron"). Please have a look at * https://getbootstrap.com/docs/5.0/migration/ * https://getbootstrap.com/docs/4.6/migration/ +- Moved the resolving of references to Person Records to separate + example which can be disabled ### Deprecated (for soon-to-be removed features) diff --git a/Makefile b/Makefile index 0aa47756d72dcfcaff88efb673b5fa5044f8e05a..4c64a38d01f0273059e29f6dac0448967b005b86 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ LIBS_DIR = $(abspath libs) TEST_CORE_DIR = $(abspath test/core/) TEST_EXT_DIR = $(abspath test/ext) TEST_SSS_DIR =$(abspath test/server_side_scripting) -LIBS = fonts css/fonts css/bootstrap.css css/bootstrap-icons.css js/state-machine.js js/jquery.js js/showdown.js js/dropzone.js css/dropzone.css js/loglevel.js js/leaflet.js css/leaflet.css css/images js/leaflet-latlng-graticule.js js/proj4.js js/proj4leaflet.js js/leaflet-coordinates.js css/leaflet-coordinates.css js/leaflet-graticule.js js/bootstrap-select.js css/bootstrap-select.css js/bootstrap-autocomplete.min.js js/plotly.js js/pako.js js/utif.js js/bootstrap.js +LIBS = fonts css/fonts css/bootstrap.css css/bootstrap-icons.css js/state-machine.js js/jquery.js js/showdown.js js/dropzone.js css/dropzone.css js/loglevel.js js/leaflet.js css/leaflet.css css/images js/leaflet-latlng-graticule.js js/proj4.js js/proj4leaflet.js js/leaflet-coordinates.js css/leaflet-coordinates.css js/leaflet-graticule.js js/bootstrap-select.js css/bootstrap-select.css js/bootstrap-autocomplete.min.js js/plotly.js js/pako.js js/utif.js js/bootstrap.js js/qrcode.js TEST_LIBS = $(LIBS) js/qunit.js css/qunit.css $(subst $(TEST_CORE_DIR)/,,$(shell find $(TEST_CORE_DIR)/)) @@ -299,6 +299,9 @@ $(LIBS_DIR)/js/pako.js: unzip $(LIBS_DIR)/js $(LIBS_DIR)/js/utif.js: unzip $(LIBS_DIR)/js ln -s $(LIBS_DIR)/UTIF-8205c1f/UTIF.js $@ +$(LIBS_DIR)/js/qrcode.js: unzip $(LIBS_DIR)/js + ln -s $(LIBS_DIR)/qrcode-1.4.4/qrcode.min.js $@ + $(addprefix $(LIBS_DIR)/, js css): mkdir $@ || true diff --git a/build.properties.d/00_default.properties b/build.properties.d/00_default.properties index 58d20c521100bbd43e094c2da402abc38ea555bc..412773eb86d46151d1126d29c5e539439d68a6fe 100644 --- a/build.properties.d/00_default.properties +++ b/build.properties.d/00_default.properties @@ -42,7 +42,6 @@ # Modules enabled/disabled by default ############################################################################## BUILD_MODULE_EXT_PREVIEW=ENABLED -BUILD_MODULE_EXT_RESOLVE_REFERENCES=ENABLED BUILD_MODULE_EXT_SSS_MARKDOWN=DISABLED BUILD_MODULE_EXT_TRIGGER_CRAWLER_FORM=DISABLED BUILD_MODULE_EXT_AUTOCOMPLETE=ENABLED @@ -51,10 +50,15 @@ BUILD_MODULE_EXT_BOTTOM_LINE_TABLE_PREVIEW=DISABLED BUILD_MODULE_EXT_BOTTOM_LINE_TIFF_PREVIEW=DISABLED BUILD_MODULE_EXT_BOOKMARKS=ENABLED BUILD_MODULE_EXT_ANNOTATION=ENABLED +BUILD_MODULE_EXT_COSMETICS_LINKIFY=DISABLED +BUILD_MODULE_EXT_QRCODE=ENABLED BUILD_MODULE_USER_MANAGEMENT=ENABLED BUILD_MODULE_USER_MANAGEMENT_CHANGE_OWN_PASSWORD_REALM=CaosDB +BUILD_MODULE_EXT_RESOLVE_REFERENCES=ENABLED +BUILD_EXT_REFERENCES_CUSTOM_RESOLVER=person_reference + ############################################################################## # Navbar properties ############################################################################## @@ -148,4 +152,7 @@ MODULE_DEPENDENCIES=( ext_sss_markdown.js ext_trigger_crawler_form.js ext_bookmarks.js + ext_cosmetics.js + qrcode.js + ext_qrcode.js ) diff --git a/doc/QueryShortcuts/doc.pdf b/doc/QueryShortcuts/doc.pdf deleted file mode 100644 index 0f7e46bb78b4f8c6cf7a30216cb9f507be747ad4..0000000000000000000000000000000000000000 Binary files a/doc/QueryShortcuts/doc.pdf and /dev/null differ diff --git a/doc/QueryShortcuts/doc.tex b/doc/QueryShortcuts/doc.tex deleted file mode 100644 index 4cd78391d1cda808dc9c332328d3a984ae5926d8..0000000000000000000000000000000000000000 --- a/doc/QueryShortcuts/doc.tex +++ /dev/null @@ -1,192 +0,0 @@ -\documentclass{article} -% General document formatting -\usepackage[margin=0.7in]{geometry} -\usepackage[parfill]{parskip} -\usepackage[utf8]{inputenc} -\usepackage{graphicx} - -% Related to math -\usepackage{amsmath,amssymb,amsfonts,amsthm} -\title{Documentation Query Shortcuts} - -\begin{document} -\maketitle -\section{Introduction}\label{introduction} - -The WebUI supports the creation of query shortcuts which appear below -the normal query input field. These shortcuts facilitate looking for -data as query strings which are used frequently. They can be stored and -reused. - -\begin{figure}[h] -\centering -\includegraphics[width=.8\textwidth]{shortcut_toolbox.png} -\caption{The Shortcuts in the Query Panel; Note the Toolbox for in the top -right} -\end{figure} - -There are two ways to integrate query templates into the WebUI: - -\begin{itemize} -\item - Global shortcuts are integrated by the webmaster only. They are - defined and stored in a\\ - \texttt{./conf/ext/json/globale\_query\_shortcuts.json} in the root - directory of the webui. -\item - User-defined templates can be defined by users and are only visible - for the user who created them. In this sense, user-defined shortcuts - are also private, whereas global shortcuts are always publicly - visible. -\end{itemize} - -\section{User-defined Query Shortcuts}\label{user-defined-query-shortcuts} - -\subsection{Create a New Shortcut}\label{create-a-new-shortcut} - -New Query Shortcuts can be generated by any authenticated user with -sufficient write permissions. - -In the web interface, click \texttt{Query}. In the \texttt{Shortcuts} -section, click the wrench (on the right side). - -In the drop-down menu, click \texttt{Create}. - -It now opens a form with two input fields, \texttt{Description} and -\texttt{Query}. -\begin{figure}[h] -\centering -\includegraphics[width=.6\textwidth]{create_shortcut.png} -\caption{The view to create a new shortcut} -\end{figure} - -See \ref{basic-shortcut} and -\ref{advanced-shortcut} for further -explanation of the components of a Query Shortcut. - -Edit the fields and click \texttt{Submit} for the creation of the new -shortcut or click \texttt{Cancel} to cancel the process. - -The new shortcut is shown in the shortcuts section. -\begin{figure}[h] -\centering -\includegraphics[width=.6\textwidth]{create_success.png} -\caption{The view when creation was successful} -\end{figure} - -\subsection{Change an Existing Shortcut}\label{change-an-existing-shortcut} - -Existing Query Shortcuts which are visible in your shortcuts section can -be edited directly in the shortcuts section. - -In the web interface, click \texttt{Query}. In the \texttt{Shortcuts} -section, click the wrench (on the right side). - -In the drop-down menu, click \texttt{Edit}. - -\begin{figure}[h] -\centering -\includegraphics[width=.6\textwidth]{choose_edit.png} -\caption{Choosing which shortcut to edit} -\end{figure} -Every editable shortcut (note: global shortcuts are not editable in the -webinterface at all) will receive a new button \texttt{Edit} - -Click \texttt{Edit} of the shortcut that is to be changed. - -It now opens a form with two input fields, \texttt{Description} and -\texttt{Query}, pre-filled. - -See \ref{basic-shortcut} and -\ref{advanced-shortcut} for further -explanation of the components of a Query Shortcut. - -Edit the fields and click \texttt{Submit} for the creation of the new -shortcut or click \texttt{Cancel} to cancel the process. - -The updated shortcut is shown in the shortcuts section. - -See the - -\subsection{Delete an Existing Shortcut}\label{delete-an-existing-shortcut} - -Existing Query Shortcut which are visible in your shortcuts section can -be edited directly in the shortcuts section. - -In the web interface, click \texttt{Query}. In the \texttt{Shortcuts} -section, click the wrench (on the right side). - -In the drop-down menu, click \texttt{Delete}. -\begin{figure}[h] -\centering -\includegraphics[width=.6\textwidth]{delete_shortcuts.png} -\caption{Choosing which shortcuts to delete} -\end{figure} - -Every user-defined shortcut (note: global shortcuts are not deletable in -the webinterface at all) will receive checkbox and \texttt{Delete} and -\texttt{Cancel} buttons appear at the bottom of the shortcuts section. - -Check all shortcuts which are to be deleted and click \texttt{Delete} or -click \texttt{Cancel} to cancel the deletion. - -All deleted shortcuts are marked as deleted afterwards and will not -appear again in the shortcuts section after reload. - -\subsection{Basic Shortcut}\label{basic-shortcut} - -The \texttt{Description} is a verbose definition of the query, -e.g.~``Search for experiments and return a table.''. It will be the text -that is visible in the shortcuts section. - -The \texttt{Query} is the query that will be executed with the shortcut. -It adheres to the definition of the CaosDB Query Language (CQL). - -The corresponding query of our example is -\texttt{SELECT\ date,\ name\ FROM\ Experiment}. - -\subsection{Advanced Shortcut}\label{advanced-shortcut} - -The basic shortcut does not allow for any parameterization. It is just a -plain string or like a bookmark. - -Advanced shortcuts use a special syntax, where text placeholders are -used to define parameters of the shortcut. The parameters can be set by -the user at the time of the execution. An example can best illustrate -what that means: - -Suppose you want to search for experiments by their year. The query for -that would be -\texttt{SELECT\ date,\ name\ FROM\ Experiment\ WITH\ date\ IN\ 2018}. - -Now, the actual year in the query can be made editable by replacing the -year \texttt{2018} with \texttt{\{year\}}. - -The \texttt{Description} now must also contain this placeholder -\texttt{\{year\}}, e.g.~``Search for experiements conducted in year -\{year\}''. When the shortcut is displayed in the shortcuts section -below the query input field, the placeholder is replaced by a text input -field and the user can insert a year and execute the shortcut with the -year being inserted into the query. - -\subsubsection{Placeholders}\label{placeholders} - -The placeholders have simple rules. A placeholder always starts and ends -with curly brackets, like in the example \texttt{\{year\}}. The text -inside the brackets (the placeholder's \emph{id}) may contain any -combination of alphanumeric signs (0-9,a-z,A-Z). The use of special -characters like colons, commas or the like is discouraged. They are -reserved for future extensions of the placeholders. Apart from that, you -are free to choose any placeholder \emph{id} that seems suitable for -you. - -Both components of the query shortcut (description and query) must -contain the same set of placeholders, otherwise the query shortcuts -might not work as intended. If there is a \texttt{\{year\}} in the -query, there must be a \texttt{\{year\}} in the description. - -Each placeholder \emph{id} must occur only once in both components -- if -you need to use two years in your shortcut you have to use -\texttt{\{year1\}} and \texttt{\{year2\}} or any other combinations of -placeholder \emph{ids}. -\end{document} diff --git a/libs/qrcode-1.4.4.zip b/libs/qrcode-1.4.4.zip new file mode 100644 index 0000000000000000000000000000000000000000..ed1e0f854985cfcec8cd4f419fe27c195f39c22c Binary files /dev/null and b/libs/qrcode-1.4.4.zip differ diff --git a/misc/ext_cosmetics_test_data.py b/misc/ext_cosmetics_test_data.py new file mode 100755 index 0000000000000000000000000000000000000000..786f46c2d6bcd1d55488a15e2c4f50085f331950 --- /dev/null +++ b/misc/ext_cosmetics_test_data.py @@ -0,0 +1,67 @@ +#!/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 caosdb as db + +# clean +old = db.execute_query("FIND Test*") +if len(old): + old.delete() + +# data model +datamodel = db.Container() +datamodel.extend([ + db.Property("TestProp", datatype=db.TEXT), + db.RecordType("TestRecordType"), +]) + +datamodel.insert() + + +# test data +testdata = db.Container() + +test_cases = [ + "no link", + "https://example.com", + "https://example.com and http://example.com", + "this is text https://example.com", + "this is text https://example.com and this as well", + "this is text https://example.com and another linke https://example.com", + "this is text https://example.com and another linke https://example.com and more text", + ("this is a lot of text with links in it Lorem ipsum dolor sit amet, " + "consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore " + "et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud " + "exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. " + "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum " + "dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non " + "proident, sunt in culpa qui officia deserunt mollit anim id est " + "laborum.https://example.com and another linke https://example.com and " + "more text and here comes a very long link: " + "https://example.com/this/has/certainly/more/than/40/characters/just/count/if/you/dont/believe/it.html"), +] +for test_case in test_cases: + testdata.append(db.Record().add_parent("TestRecordType").add_property("TestProp", + test_case)) +testdata.insert() diff --git a/src/core/css/tour.css b/src/core/css/tour.css index beec314ff8f8aa944e1fbb1fd9efd20e1fb17aa5..d772c79e8e9684690796b8acbd35b2116adf6f9b 100644 --- a/src/core/css/tour.css +++ b/src/core/css/tour.css @@ -251,6 +251,10 @@ div.caosdb-v-tour-toc-show { border: none; } +.caosdb-v-tour-toc-header { + margin-left: 0.75rem; +} + /* For elements in popovers which are not for clicking but only illustrative. */ .caosdb-v-tour-unclickable { cursor: text !important; diff --git a/src/core/js/ext_cosmetics.js b/src/core/js/ext_cosmetics.js index 4d935a2a5afecd699fee1a24416c06b30d1adc46..f4f281123b39a87b7ef6848db4e84a81b5e30d9c 100644 --- a/src/core/js/ext_cosmetics.js +++ b/src/core/js/ext_cosmetics.js @@ -28,22 +28,33 @@ */ var cosmetics = new function () { + /** + * Cut-off length of links. When linkify processes the links any href + * longer than this will be cut off at character 25 and "[...]" will be + * appended for the link text. + */ + var _link_cut_off_length = 40; + var _linkify = function () { $('.caosdb-f-property-text-value').each(function (index) { - // TODO also extract and convert links surrounded by other text - if (/^https?:\/\//.test(this.innerText)) { - var uri = this.innerText; - var text = uri + if (/https?:\/\//.test(this.innerText)) { + var result = this.innerText.replace(/https?:\/\/[^\s]*/g, function (href, index) { + var link_text = href; + if (_link_cut_off_length > 4 && link_text.length > _link_cut_off_length) { + link_text = link_text.substring(0, _link_cut_off_length - 5) + "[...]"; + } + + return `<a title="Open ${href} in a new tab." target="_blank" class="caosdb-v-property-href-value" href="${href}">${link_text} <i class="bi bi-box-arrow-up-right"></i></a>`; + }); - $(this).parent().css("overflow", "hidden"); - $(this).parent().css("text-overflow", "ellipsis"); - $(this).html(`<a class="caosdb-v-property-href-value" href="${uri}">${text} <i class="bi bi-box-arrow-up-right"></i></a>`); + $(this).html(result); } }); } /** - * Convert any text-value beginning with 'http(s)://' into a link. + * Convert any substring of a text-value beginning with 'http(s)://' into a + * link. * * A listener detects edit-mode changes and previews */ @@ -57,6 +68,7 @@ var cosmetics = new function () { } this.init = function () { + this.linkify = linkify; if ("${BUILD_MODULE_EXT_COSMETICS_LINKIFY}" == "ENABLED") { linkify(); } diff --git a/src/core/js/ext_map.js b/src/core/js/ext_map.js index 719d4f77943fdfca562961838dc02d95d5fd5cb1..c20cdefc2e9de73a21db81e3a7d5ebacebe73416 100644 --- a/src/core/js/ext_map.js +++ b/src/core/js/ext_map.js @@ -1470,7 +1470,7 @@ var caosdb_map = new function () { */ this.make_entity_name_label = function (entity) { const name = getEntityName(entity); - const id = getEntityId(entity); + const id = getEntityID(entity); const entity_on_page = $(`#${id}`).length > 0; const href = entity_on_page ? `#${id}` : connection.getBasePath() + `Entity/${id}` diff --git a/src/core/js/ext_qrcode.js b/src/core/js/ext_qrcode.js new file mode 100644 index 0000000000000000000000000000000000000000..d075ef884a89d407cb1e79b98f2045c6d4e25a26 --- /dev/null +++ b/src/core/js/ext_qrcode.js @@ -0,0 +1,201 @@ +/* + * This file is a part of the CaosDB Project. + * + * Copyright (C) 2021 IndiScale GmbH <info@indiscale.com> + * Copyright (C) 2021 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/>. + */ + +"use strict"; + +/** + * Adds QR-Code generation to entities. + * + * @author Timm Fitschen + */ +var ext_qrcode = function ($, connection, getEntityVersion, getEntityID, QRCode, logger) { + + const _buttons_list_class = "caosdb-v-entity-header-buttons-list"; + const _qrcode_button_class = "caosdb-f-entity-qrcode-button"; + const _qrcode_canvas_container = "caosdb-f-entity-qrcode"; + const _qrcode_link_container = "caosdb-f-entity-qrcode-link"; + const _qrcode_icon = `<i class="bi bi-upc"></i>`; + + /** + * Create a new QR Code and a caption with a link, either linking to the + * entity head or to the exact version of the entity, based on the selected + * radio buttons and insert it into the modal. + * + * @param {HTMLElement} modal + * @param {string} entity_id + * @param {string} entity_version + */ + var update_qrcode = function (modal, entity_id, entity_version) { + modal = $(modal); + const uri = modal.find("input[name=entity-qrcode-versioned]:checked").val(); + var display_version = ""; + if (uri.indexOf("@") > -1) { + display_version = `@${entity_version.substring(0,8)}`; + } + const description = `Entity <a href="${uri}">${entity_id}${display_version}</a>`; + modal.find(`.${_qrcode_canvas_container}`).empty(); + modal.find(`.${_qrcode_link_container}`).empty().append(description); + QRCode.toCanvas(uri, { + "scale": 6 + }).then((canvas) => { + modal.find(`.${_qrcode_canvas_container}`).empty().append(canvas); + }).catch(logger.error); + } + + /** + * Create modal which shows the QR Code and a form where the user can choose + * whether the QR Code links to the entity head or the exact version of the + * entity. + * + * @param {string} modal_id - id of the resulting HTMLElement + * @param {string} entity_id + * @param {string} entity_version + * @return {HTMLElement} the resulting modal. + */ + var create_qrcode_modal = function (modal_id, entity_id, entity_version) { + const uri = `${connection.getEntityUri([entity_id])}`; + const short_version = entity_version.substring(0, 8); + const modal = $(`<div class="modal fade" id="${modal_id}" tabindex="-1" aria-labelledby="${modal_id}-label" aria-hidden="true"> + <div class="modal-dialog"> + <div class="modal-content"> + <div class="modal-header"> + <h4 class="modal-title" id="${modal_id}-label">QR Code</h5> + <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> + </div> + <div class="modal-body text-center"> + <div class="${_qrcode_canvas_container}"></div> + <div class="${_qrcode_link_container}"></div> + </div> + <div class="modal-footer justify-content-start"> + <form> + <div class="form-check"> + <label class="form-check-label"> + <input value="${uri}" class="form-check-input" type="radio" name="entity-qrcode-versioned" checked> + Link to this entity. + </label> + </div> + <div class="form-check"> + <label class="form-check-label" for="flexRadioDefault1"> + <input value="${uri}@${entity_version}" class="form-check-input" type="radio" name="entity-qrcode-versioned"> + Link to this exact version of this entity. + </label> + </div> + </form> + </div> + </div> + </div> + </div>`); + modal.find("form").change(() => { + update_qrcode(modal, entity_id, entity_version); + }); + return modal[0]; + } + + /** + * Click handler of the QR Code button. The click event opens a modal showing + * the QR Code and a form where the user can choose whether the QR Code links + * to the entity head or the exact version of the entity. + * + * @param {string} entity_id + * @param {string} entity_version + */ + var qrcode_button_click_handler = function (entity_id, entity_version) { + const modal_id = `qrcode-modal-${entity_id}-${entity_version}`; + var modal_element = document.getElementById(modal_id); + if (modal_element) { + // toggle modal + const modal = bootstrap.Modal.getInstance(modal_element); + modal.toggle(); + } else { + modal_element = create_qrcode_modal(modal_id, entity_id, entity_version); + update_qrcode(modal_element, entity_id, entity_version); + $("body").append(modal_element); + const options = {}; + const modal = new bootstrap.Modal(modal_element, options); + modal.show(); + } + } + + /** + * Create a button which opens the QR Code modal on click. + * + * @param {string} entity_id + * @param {string} entity_version + * @return {HTMLElement} the newly created button. + */ + var create_qrcode_button = function (entity_id, entity_version) { + const button = $(`<button title="Create QR Code" type="button" class="${_qrcode_button_class} caosdb-v-entity-qrcode-button btn">${_qrcode_icon}</button>`); + button.click(() => { + qrcode_button_click_handler(entity_id, entity_version); + }); + return button[0]; + } + + /** + * Add a qrcode button to a given entity. + * @param {HTMLElement} entity + */ + var add_qrcode_to_entity = function (entity) { + const entity_id = getEntityID(entity); + const entity_version = getEntityVersion(entity); + + $(entity).find(`.${_buttons_list_class}`).append(create_qrcode_button(entity_id, entity_version)); + } + + var remove_qrcode_button = function (entity) { + $(entity).find(`.${_buttons_list_class} .${_qrcode_button_class}`).remove(); + } + + var _init = function () { + for (let entity of $(".caosdb-entity-panel")) { + remove_qrcode_button(entity); + add_qrcode_to_entity(entity); + } + } + + /** + * Initialize this module and append a QR Code button to all entities panels on the page. + * + * Removes all respective buttons if present before adding a new one. + */ + var init = function () { + _init(); + + // edit-mode-listener + document.body.addEventListener(edit_mode.end_edit.type, _init, true); + }; + + return { + update_qrcode: update_qrcode, + add_qrcode_to_entity: add_qrcode_to_entity, + remove_qrcode_button: remove_qrcode_button, + create_qrcode_button: create_qrcode_button, + create_qrcode_modal: create_qrcode_modal, + qrcode_button_click_handler: qrcode_button_click_handler, + init: init + }; + +}($, connection, getEntityVersion, getEntityID, QRCode, console); + +$(document).ready(function () { + if ("${BUILD_MODULE_EXT_QRCODE}" == "ENABLED") { + caosdb_modules.register(ext_qrcode); + } +}); \ No newline at end of file diff --git a/src/core/js/ext_references.js b/src/core/js/ext_references.js index 7cd597e128e8c09da9134f42f542898fb84a4e53..fe4d618c752490400e501116470cce0f28a909ad 100644 --- a/src/core/js/ext_references.js +++ b/src/core/js/ext_references.js @@ -43,13 +43,13 @@ var isOutOfViewport = function (elem) { out.top = bounding.top < 0; out.left = bounding.left < 0; out.bottom = bounding.bottom > (window.innerHeight || - document.documentElement.clientHeight); + document.documentElement.clientHeight); out.right = bounding.right > - (window.innerWidth || document.documentElement.clientWidth); + (window.innerWidth || document.documentElement.clientWidth); out.any = - out.top || out.left || out.bottom || out.right; + out.top || out.left || out.bottom || out.right; out.all = out.top && - out.left && out.bottom && out.right; + out.left && out.bottom && out.right; return out; }; @@ -90,56 +90,56 @@ var reference_list_summary = new function () { * array. */ this.simplify_integer_numbers = function (array) { - logger.trace("enter simplify_integer_numbers", array); - var set = Array.from(new Set(array)); - - if (set.length === 0) { - return "" - } else if (set.length === 1) { - return `${set[0]}`; - } - - // sort numerically - set.sort((a, b) => a - b); - - if (set.length === 2) { - return `${set[0]}, ${set[1]}`; - } - - - var ret = `${set[0]}`; - var last = undefined; - // set[0]; - - // e.g. [1,2,3,4,5,8,9,10]; - for (const next of set) { - // append '-' to summarize consecutive numbers - if (next - last === 1 && !ret.endsWith("-")) { - ret += "-"; - } - - if (next - last > 1) { - - if (ret.endsWith("-")) { - // close previous interval and start new - ret += `${last}, ${next}`; - } else { - // no previous interval, start interval. - ret += `, ${next}`; - } - } else if (next === set[set.length - 1]) { - // finish interval if next is last item - ret += next; - break; - } + logger.trace("enter simplify_integer_numbers", array); + var set = Array.from(new Set(array)); + + if (set.length === 0) { + return "" + } else if (set.length === 1) { + return `${set[0]}`; + } + + // sort numerically + set.sort((a, b) => a - b); + + if (set.length === 2) { + return `${set[0]}, ${set[1]}`; + } + + + var ret = `${set[0]}`; + var last = undefined; + // set[0]; + + // e.g. [1,2,3,4,5,8,9,10]; + for (const next of set) { + // append '-' to summarize consecutive numbers + if (next - last === 1 && !ret.endsWith("-")) { + ret += "-"; + } + + if (next - last > 1) { + + if (ret.endsWith("-")) { + // close previous interval and start new + ret += `${last}, ${next}`; + } else { + // no previous interval, start interval. + ret += `, ${next}`; + } + } else if (next === set[set.length - 1]) { + // finish interval if next is last item + ret += next; + break; + } - last = next; + last = next; - } + } - // e.g. "1-5, 8-10" - return ret; + // e.g. "1-5, 8-10" + return ret; } /** @@ -158,19 +158,19 @@ var reference_list_summary = new function () { * @return {HTMLElement|string} generated summary */ this.generate = function (ref_infos, summary_container) { - logger.trace("enter generate", ref_infos); - if (ref_infos.length > 0 && - typeof ref_infos[0].callback === "function") { - const summary = - ref_infos[0].callback(ref_infos); - if (summary && summary_container) { - $(summary_container).append(summary); - } - logger.trace("leave generate", summary); - return summary; - } - logger.trace("leave generate, return undefined"); - return undefined; + logger.trace("enter generate", ref_infos); + if (ref_infos.length > 0 && + typeof ref_infos[0].callback === "function") { + const summary = + ref_infos[0].callback(ref_infos); + if (summary && summary_container) { + $(summary_container).append(summary); + } + logger.trace("leave generate", summary); + return summary; + } + logger.trace("leave generate, return undefined"); + return undefined; } } @@ -205,12 +205,12 @@ var resolve_references = new function () { * last scroll event. */ var scroll_listener = () => { - if (_scroll_timeout) { - clearTimeout(_scroll_timeout); - } - _scroll_timeout = setTimeout(function () { - resolve_references.update_visible_references(); - }, 500); + if (_scroll_timeout) { + clearTimeout(_scroll_timeout); + } + _scroll_timeout = setTimeout(function () { + resolve_references.update_visible_references(); + }, 500); }; @@ -220,15 +220,15 @@ var resolve_references = new function () { * visible references. */ this.init = function () { - if ("${BUILD_MODULE_EXT_RESOLVE_REFERENCES}" === "ENABLED") { - scroll_listener(); + if ("${BUILD_MODULE_EXT_RESOLVE_REFERENCES}" === "ENABLED") { + scroll_listener(); - // mainly for vertical scrolling - $(window).scroll(scroll_listener); + // mainly for vertical scrolling + $(window).scroll(scroll_listener); - // for horizontal scrolling. - $(".caosdb-value-list").scroll(scroll_listener); - } + // for horizontal scrolling. + $(".caosdb-value-list").scroll(scroll_listener); + } } /** @@ -241,9 +241,9 @@ var resolve_references = new function () { * */ this.is_in_viewport_vertically = function (elem) { - var out = - isOutOfViewport(elem); - return !(out.top || out.bottom); + var out = + isOutOfViewport(elem); + return !(out.top || out.bottom); } /** Check if an element is inside of the viewport on the horizontal axis. @@ -257,35 +257,19 @@ var resolve_references = new function () { * */ this.is_in_viewport_horizontally = function (elem) { - var scrollbox = elem.parentElement.parentElement; - // Check this condition only if the grand parent is a list and return true - // otherwise. - if (scrollbox.classList.contains("caosdb-value-list") == - true) { - var boundel = elem.getBoundingClientRect(); - var boundscroll = scrollbox.getBoundingClientRect(); - var leftcrit = boundel.right > boundscroll.left; - var rightcrit = boundel.left < boundscroll.right; - return leftcrit && rightcrit; - } else { - return true; - } - } - - - /** - * Return the name of a person as firstname + lastname - */ - this.get_person_str = function (el) { - var valpr = getProperties(el); - if (valpr == undefined) { - return; - } - return valpr.filter(valprel => - valprel.name.toLowerCase() == "firstname")[0].value + - " " + - valpr.filter(valprel => valprel.name.toLowerCase() == - "lastname")[0].value; + var scrollbox = elem.parentElement.parentElement; + // Check this condition only if the grand parent is a list and return true + // otherwise. + if (scrollbox.classList.contains("caosdb-value-list") == + true) { + var boundel = elem.getBoundingClientRect(); + var boundscroll = scrollbox.getBoundingClientRect(); + var leftcrit = boundel.right > boundscroll.left; + var rightcrit = boundel.left < boundscroll.right; + return leftcrit && rightcrit; + } else { + return true; + } } @@ -296,13 +280,13 @@ var resolve_references = new function () { * {string} par - parent name. @return {boolean} */ this.is_child = function (entity, par) { - var pars = resolve_references.getParents(entity); - for (const thispar of pars) { - if (thispar.name === par) { - return true; - } - } - return false; + var pars = resolve_references.getParents(entity); + for (const thispar of pars) { + if (thispar.name === par) { + return true; + } + } + return false; } /** @@ -318,46 +302,56 @@ var resolve_references = new function () { /** * Return a reference_info for an entity. * + * You may add your own custom resolver by specifying a JS module + * via the `BUILD_EXT_REFERENCES_CUSTOM_RESOLVER` build + * variable. The custom resolver has to be a JS module (typically + * located at caosdb-webui/src/ext/js), the name of which is given + * as the value of `BUILD_EXT_REFERENCES_CUSTOM_RESOLVER`. It has + * to provide a `resolve` function that takes the entity id to be + * resolved as a string and returns a `reference_info` object with + * the resolved custom reference as a `text` property. + * + * See caosdb-webui/src/ext/js/person_reference_resolver.js for an + * example. + * * TODO refactor to be configurable. @async @param {string} id - the id of * the entity which is to be resolved. @return {reference_info} */ this.resolve_reference = async function (id) { - const custom_reference_resolver = window["${BUILD_EXT_REFERENCES_CUSTOM_RESOLVER}"]; - if (custom_reference_resolver && typeof custom_reference_resolver.resolve === "function") { - // try custom_reference_resolver and fall-back to standard implementation - var ret = await custom_reference_resolver.resolve(id); - if (ret) { - return ret; - } - } - - const entity = (await resolve_references.retrieve(id))[0]; - - // TODO handle multiple parents - const par = resolve_references.getParents(entity)[0] || {}; - - var ret = { - "text": id - }; - if (getEntityHeadingAttribute(entity, "path") !== - undefined || par.name == "Image") { - // show file name - var pths = getEntityHeadingAttribute(entity, "path") - .split("/"); - ret["text"] = pths[pths.length - 1]; - } else if (par.name === "Person") { - ret["text"] = this.get_person_str(entity); - } else if (par.name === "TestReferenced" && typeof resolve_references.test_resolver === "function") { - // this is a test case, initialized by the test suite. - ret = resolve_references.test_resolver(entity); - } else { - var name = getEntityName(entity); - if (typeof name !== "undefined" && name.length > 0) { - ret["text"] = name; - } - } - + const custom_reference_resolver = window["${BUILD_EXT_REFERENCES_CUSTOM_RESOLVER}"]; + if (custom_reference_resolver && typeof custom_reference_resolver.resolve === "function") { + // try custom_reference_resolver and fall-back to standard implementation + var ret = await custom_reference_resolver.resolve(id); + if (ret) { return ret; + } + } + + const entity = (await resolve_references.retrieve(id))[0]; + + // TODO handle multiple parents + const par = resolve_references.getParents(entity)[0] || {}; + + var ret = { + "text": id + }; + if (getEntityHeadingAttribute(entity, "path") !== + undefined || par.name == "Image") { + // show file name + var pths = getEntityHeadingAttribute(entity, "path") + .split("/"); + ret["text"] = pths[pths.length - 1]; + } else if (par.name === "TestReferenced" && typeof resolve_references.test_resolver === "function") { + // this is a test case, initialized by the test suite. + ret = resolve_references.test_resolver(entity); + } else { + var name = getEntityName(entity); + if (typeof name !== "undefined" && name.length > 0) { + ret["text"] = name; + } + } + + return ret; } @@ -372,12 +366,12 @@ var resolve_references = new function () { * @return {HTMLElement} the new/existing target element. */ this.add_target = function (element) { - if(element.getElementsByClassName(this._target_class).length > 0){ - return element.getElementsByClassName(this._target_class); - } else { - return $(`<span class="${this._target_class}"/>`) - .appendTo(element)[0]; - } + if(element.getElementsByClassName(this._target_class).length > 0){ + return element.getElementsByClassName(this._target_class); + } else { + return $(`<span class="${this._target_class}"/>`) + .appendTo(element)[0]; + } } /** @@ -388,14 +382,14 @@ var resolve_references = new function () { * @return {reference_info} the resolved reference information */ this.update_single_resolvable_reference = async function (rs) { - $(rs).find(".caosdb-id-button").hide(); - const target = resolve_references.add_target(rs); - const id = getEntityID(rs); - target.textContent = id; - const resolved_entity_info = ( - await resolve_references.resolve_reference(id)); - target.textContent = resolved_entity_info.text; - return resolved_entity_info; + $(rs).find(".caosdb-id-button").hide(); + const target = resolve_references.add_target(rs); + const id = getEntityID(rs); + target.textContent = id; + const resolved_entity_info = ( + await resolve_references.resolve_reference(id)); + target.textContent = resolved_entity_info.text; + return resolved_entity_info; } @@ -411,10 +405,10 @@ var resolve_references = new function () { * @return {HTMLElement} a summary field. */ this.add_summary_field = function (list_values) { - const summary = $( - `<div class="${resolve_references._summary_class}"/>`); - $(list_values).prepend(summary); - return summary[0]; + const summary = $( + `<div class="${resolve_references._summary_class}"/>`); + $(list_values).prepend(summary); + return summary[0]; } this._summary_class = "caosdb-resolve-reference-summary"; @@ -426,9 +420,9 @@ var resolve_references = new function () { this._unresolved_class_name = "caosdb-resolvable-reference"; this.get_resolvable_properties = function (container) { - const _unresolved_class_name = this._unresolved_class_name; - return $(container).find(".caosdb-f-property-value").has( - `.${_unresolved_class_name}`).toArray(); + const _unresolved_class_name = this._unresolved_class_name; + return $(container).find(".caosdb-f-property-value").has( + `.${_unresolved_class_name}`).toArray(); } @@ -442,115 +436,115 @@ var resolve_references = new function () { * @param {HTMLElement} container */ this.update_visible_references = async function (container) { - const property_values = resolve_references - .get_resolvable_properties(container || document.body); - - const _unresolved_class_name = resolve_references - ._unresolved_class_name; - - // Loop over all property values in the container element. Note: each property_value can be a single reference or a list of references. - for (const property_value of property_values) { - var lists = findElementByConditions( - property_value, - x => x.classList.contains("caosdb-value-list"), - x => x.classList.contains("caosdb-preview-container")) - lists = $(lists).has(`.${_unresolved_class_name}`); - - if (lists.length > 0) { - logger.debug("processing list of references", lists); - - for (var i = 0; i < lists.length; i++) { - const list = lists[i]; - if (resolve_references - .is_in_viewport_vertically(list)) { - const rs = $(list).find( - `.${_unresolved_class_name}`) - .toggleClass(_unresolved_class_name, false); - - // First resolve only one reference. If the `ref_info` - // indicates that a summary is to be generated from the - // list of references, retrieve all other other - // references. Otherwise retrieve only those which are - // visible in the viewport horizontally and trigger the - // retrieval of the others when they are scrolled into - // the view port. - const first_ref_info = await resolve_references - .update_single_resolvable_reference(rs[0]); - - first_ref_info["index"] = 0; - - if (typeof first_ref_info.callback === "function") { - // there is a callback function, hence we need to - // generate a summary. - logger.debug("loading all references for summary", - rs); - const summary_field = resolve_references - .add_summary_field(property_value); - - // collect ref infos for the summary - const ref_infos = [first_ref_info]; - for (var j = 1; j < rs.length; j++) { - const ref_info = resolve_references - .update_single_resolvable_reference(rs[j]); - ref_info["index"] = j; - ref_infos.push(ref_info); - } - - // wait for resolution of references, - // then generate the summary, - // dispatch event when ready. - Promise.all(ref_infos) - .then(_ref_infos => {reference_list_summary - .generate(_ref_infos, summary_field);}) - .then(() => { - summary_field.dispatchEvent( - resolve_references - .summary_ready_event - );}) - .catch((err) => { - logger.error(err); - }) - - } else { - // no summary to be generated - - logger.debug("lazy loading references", rs); - for (var j = 1; j < rs.length; j++) { - // mark others to be loaded later and only if - // visible - $(rs[j]).toggleClass(_unresolved_class_name, true); - } - } - } - } - } - - // Load all remaining references. These are single reference values - // and those references from lists which are left for lazy loading. - const rs = findElementByConditions( - property_value, - x => x.classList.contains(`${_unresolved_class_name}`), - x => x.classList.contains("caosdb-preview-container")); - for (var i = 0; i < rs.length; i++) { - if (resolve_references.is_in_viewport_vertically( - rs[i]) && - resolve_references.is_in_viewport_horizontally( - rs[i])) { - logger.debug("processing single references", rs); - $(rs[i]).toggleClass(_unresolved_class_name, false); - - // discard return value as it is not needed for any summary - // generation as above. - resolve_references.update_single_resolvable_reference(rs[i]); - } - } + const property_values = resolve_references + .get_resolvable_properties(container || document.body); + + const _unresolved_class_name = resolve_references + ._unresolved_class_name; + + // Loop over all property values in the container element. Note: each property_value can be a single reference or a list of references. + for (const property_value of property_values) { + var lists = findElementByConditions( + property_value, + x => x.classList.contains("caosdb-value-list"), + x => x.classList.contains("caosdb-preview-container")) + lists = $(lists).has(`.${_unresolved_class_name}`); + + if (lists.length > 0) { + logger.debug("processing list of references", lists); + + for (var i = 0; i < lists.length; i++) { + const list = lists[i]; + if (resolve_references + .is_in_viewport_vertically(list)) { + const rs = $(list).find( + `.${_unresolved_class_name}`) + .toggleClass(_unresolved_class_name, false); + + // First resolve only one reference. If the `ref_info` + // indicates that a summary is to be generated from the + // list of references, retrieve all other other + // references. Otherwise retrieve only those which are + // visible in the viewport horizontally and trigger the + // retrieval of the others when they are scrolled into + // the view port. + const first_ref_info = await resolve_references + .update_single_resolvable_reference(rs[0]); + + first_ref_info["index"] = 0; + + if (typeof first_ref_info.callback === "function") { + // there is a callback function, hence we need to + // generate a summary. + logger.debug("loading all references for summary", + rs); + const summary_field = resolve_references + .add_summary_field(property_value); + + // collect ref infos for the summary + const ref_infos = [first_ref_info]; + for (var j = 1; j < rs.length; j++) { + const ref_info = resolve_references + .update_single_resolvable_reference(rs[j]); + ref_info["index"] = j; + ref_infos.push(ref_info); + } + + // wait for resolution of references, + // then generate the summary, + // dispatch event when ready. + Promise.all(ref_infos) + .then(_ref_infos => {reference_list_summary + .generate(_ref_infos, summary_field);}) + .then(() => { + summary_field.dispatchEvent( + resolve_references + .summary_ready_event + );}) + .catch((err) => { + logger.error(err); + }) + + } else { + // no summary to be generated + + logger.debug("lazy loading references", rs); + for (var j = 1; j < rs.length; j++) { + // mark others to be loaded later and only if + // visible + $(rs[j]).toggleClass(_unresolved_class_name, true); + } + } } } + } + + // Load all remaining references. These are single reference values + // and those references from lists which are left for lazy loading. + const rs = findElementByConditions( + property_value, + x => x.classList.contains(`${_unresolved_class_name}`), + x => x.classList.contains("caosdb-preview-container")); + for (var i = 0; i < rs.length; i++) { + if (resolve_references.is_in_viewport_vertically( + rs[i]) && + resolve_references.is_in_viewport_horizontally( + rs[i])) { + logger.debug("processing single references", rs); + $(rs[i]).toggleClass(_unresolved_class_name, false); + + // discard return value as it is not needed for any summary + // generation as above. + resolve_references.update_single_resolvable_reference(rs[i]); + } + } + } + } } $(document).ready(function () { if ("${BUILD_MODULE_EXT_RESOLVE_REFERENCES}" === "ENABLED") { - caosdb_modules.register(resolve_references); + caosdb_modules.register(resolve_references); } }); diff --git a/src/core/js/fileupload.js b/src/core/js/fileupload.js index 31d86589286f1761f481cc9d9bb6a557a63cbce1..8a988e86a6a9b5bbb39f39a37d63b32b144c748d 100644 --- a/src/core/js/fileupload.js +++ b/src/core/js/fileupload.js @@ -156,7 +156,7 @@ var fileupload = new function() { // get property-value input element (in case of FILE property) var input = $(property).find(".caosdb-f-property-value input"); var set_value = function(entity) { - input.val(getEntityId(entity)); + input.val(getEntityID(entity)); } if (input.length == 0) { @@ -207,7 +207,7 @@ var fileupload = new function() { getEntityName(entity) + `</code> has been uploaded.</div>`); input.after(`<a class="btn btn-secondary btn-sm" - href="` + connection.getEntityUri([getEntityId(entity)]) + `" target= "_blank">` + + href="` + connection.getEntityUri([getEntityID(entity)]) + `" target= "_blank">` + getEntityName(entity) + `</a>`); }; diff --git a/src/core/js/webcaosdb.js b/src/core/js/webcaosdb.js index 74dc62be15551f987707253ca201777f7c3929ac..dbb4e269a247cbbc40f1ea58623ec7b515dc2d57 100644 --- a/src/core/js/webcaosdb.js +++ b/src/core/js/webcaosdb.js @@ -812,7 +812,7 @@ this.transaction = new function () { $(updatePanel).insertBefore(entity); // create and add waiting notification updatePanel.appendChild(transaction.update.createWaitRetrieveNotification()); - let entityId = getEntityId(entity); + let entityId = getEntityID(entity); transaction.update.retrieveOldEntityXmlString(entityId).then(xmlstr => { app.openForm(xmlstr); }, err => { @@ -1524,19 +1524,6 @@ function removeAllWaitingNotifications(elem) { return elem; } -/** - * Extract the ID of an entity by parsing the textContent of the first occuring element with - * class `caosdb-id`. - * - * @param {HTMLElement} entity - * @returns {Number} ID of entity. - */ -function getEntityId(entity) { - let id = Number.parseInt(entity.getElementsByClassName("caosdb-id")[0].textContent); - if (isNaN(id)) throw new Error("id was NaN"); - return id; -} - // TODO remove and use connection.post /** * Post an xml document to basepath/Entity diff --git a/src/core/xsl/navbar.xsl b/src/core/xsl/navbar.xsl index ee4df81be60558e6b6aa2e558096d7420636349f..529945d7c37a95a0687473dce34f0269e2942c92 100644 --- a/src/core/xsl/navbar.xsl +++ b/src/core/xsl/navbar.xsl @@ -135,7 +135,7 @@ <span id="caosdb-f-bookmarks-collection-counter" class="badge bg-secondary">0</span> Bookmarks </a> - <ul class="dropdown-menu dropdown-menu-light" aria-labelledby="navbarBookmarkMenuLink"> + <ul class="dropdown-menu dropdown-menu-end dropdown-menu-light" aria-labelledby="navbarBookmarkMenuLink"> <li class="disabled" id="caosdb-f-bookmarks-collection-link" title="Show all bookmarked entities."> <a class="dropdown-item">Show all</a></li> @@ -234,10 +234,10 @@ <i class="bi-person-fill"></i> <span class="caret"></span> </a> - <ul class="dropdown-menu dropdown-menu-light"> + <ul class="dropdown-menu dropdown-menu-end dropdown-menu-light"> <xsl:if test="/Response/@realm='${BUILD_MODULE_USER_MANAGEMENT_CHANGE_OWN_PASSWORD_REALM}'"> <li> - <a title="Change your password." href="#" data-toggle="modal" data-target="#caosdb-f-change-password-form">Change Password</a> + <a class="dropdown-item" title="Change your password." href="#" data-bs-toggle="modal" data-bs-target="#caosdb-f-change-password-form">Change Password</a> </li> </xsl:if> <li> diff --git a/doc/QueryShortcuts/choose_edit.png b/src/doc/extension/images/choose_edit.png similarity index 100% rename from doc/QueryShortcuts/choose_edit.png rename to src/doc/extension/images/choose_edit.png diff --git a/doc/QueryShortcuts/create_shortcut.png b/src/doc/extension/images/create_shortcut.png similarity index 100% rename from doc/QueryShortcuts/create_shortcut.png rename to src/doc/extension/images/create_shortcut.png diff --git a/doc/QueryShortcuts/create_success.png b/src/doc/extension/images/create_success.png similarity index 100% rename from doc/QueryShortcuts/create_success.png rename to src/doc/extension/images/create_success.png diff --git a/doc/QueryShortcuts/delete_shortcuts.png b/src/doc/extension/images/delete_shortcuts.png similarity index 100% rename from doc/QueryShortcuts/delete_shortcuts.png rename to src/doc/extension/images/delete_shortcuts.png diff --git a/doc/QueryShortcuts/delete_success.png b/src/doc/extension/images/delete_success.png similarity index 100% rename from doc/QueryShortcuts/delete_success.png rename to src/doc/extension/images/delete_success.png diff --git a/doc/QueryShortcuts/edit_shortcut.png b/src/doc/extension/images/edit_shortcut.png similarity index 100% rename from doc/QueryShortcuts/edit_shortcut.png rename to src/doc/extension/images/edit_shortcut.png diff --git a/doc/QueryShortcuts/edit_success.png b/src/doc/extension/images/edit_success.png similarity index 100% rename from doc/QueryShortcuts/edit_success.png rename to src/doc/extension/images/edit_success.png diff --git a/doc/QueryShortcuts/shortcut_toolbox.png b/src/doc/extension/images/shortcut_toolbox.png similarity index 100% rename from doc/QueryShortcuts/shortcut_toolbox.png rename to src/doc/extension/images/shortcut_toolbox.png diff --git a/src/doc/extension/query_templates.rst b/src/doc/extension/query_templates.rst new file mode 100644 index 0000000000000000000000000000000000000000..015d26a21fe20bc09dc97db9c7a3e7a1ca58a0b3 --- /dev/null +++ b/src/doc/extension/query_templates.rst @@ -0,0 +1,216 @@ +Introduction +============ + +The WebUI supports the creation of query shortcuts which appear below +the normal query input field. These shortcuts facilitate looking for +data as query strings which are used frequently. They can be stored and +reused. + +.. figure:: images/shortcut_toolbox.png + :alt: The Shortcuts in the Query Panel; Note the Toolbox for in the + top right + + The Shortcuts in the Query Panel; Note the Toolbox for in the top + right + +There are two ways to integrate query templates into the WebUI: + +- | Global shortcuts are integrated by the webmaster only. They are + defined and stored in a + | ``./conf/ext/json/global_query_shortcuts.json`` in the root + directory of the webui. (See the example below.) + +- User-defined templates can be defined by users and are only visible + for the user who created them. In this sense, user-defined shortcuts + are also private, whereas global shortcuts are always publicly + visible. + +User-defined Query Shortcuts +============================ + +Create a New Shortcut +--------------------- + +New Query Shortcuts can be generated by any authenticated user with +sufficient write permissions. + +In the web interface, click ``Query``. In the ``Shortcuts`` section, +click the wrench (on the right side). + +In the drop-down menu, click ``Create``. + +It now opens a form with two input fields, ``Description`` and +``Query``. + +.. figure:: images/create_shortcut.png + :alt: The view to create a new shortcut + + The view to create a new shortcut + +See `2.4 <#basic-shortcut>`__ and `2.5 <#advanced-shortcut>`__ for +further explanation of the components of a Query Shortcut. + +Edit the fields and click ``Submit`` for the creation of the new +shortcut or click ``Cancel`` to cancel the process. + +The new shortcut is shown in the shortcuts section. + +.. figure:: images/create_success.png + :alt: The view when creation was successful + + The view when creation was successful + +Change an Existing Shortcut +--------------------------- + +Existing Query Shortcuts which are visible in your shortcuts section can +be edited directly in the shortcuts section. + +In the web interface, click ``Query``. In the ``Shortcuts`` section, +click the wrench (on the right side). + +In the drop-down menu, click ``Edit``. + +.. figure:: images/choose_edit.png + :alt: Choosing which shortcut to edit + + Choosing which shortcut to edit + +Every editable shortcut (note: global shortcuts are not editable in the +webinterface at all) will receive a new button ``Edit`` + +Click ``Edit`` of the shortcut that is to be changed. + +It now opens a form with two input fields, ``Description`` and +``Query``, pre-filled. + +See `2.4 <#basic-shortcut>`__ and `2.5 <#advanced-shortcut>`__ for +further explanation of the components of a Query Shortcut. + +Edit the fields and click ``Submit`` for the creation of the new +shortcut or click ``Cancel`` to cancel the process. + +The updated shortcut is shown in the shortcuts section. + +See the + +Delete an Existing Shortcut +--------------------------- + +Existing Query Shortcut which are visible in your shortcuts section can +be edited directly in the shortcuts section. + +In the web interface, click ``Query``. In the ``Shortcuts`` section, +click the wrench (on the right side). + +In the drop-down menu, click ``Delete``. + +.. figure:: images/delete_shortcuts.png + :alt: Choosing which shortcuts to delete + + Choosing which shortcuts to delete + +Every user-defined shortcut (note: global shortcuts are not deletable in +the webinterface at all) will receive checkbox and ``Delete`` and +``Cancel`` buttons appear at the bottom of the shortcuts section. + +Check all shortcuts which are to be deleted and click ``Delete`` or +click ``Cancel`` to cancel the deletion. + +All deleted shortcuts are marked as deleted afterwards and will not +appear again in the shortcuts section after reload. + +Basic Shortcut +-------------- + +The ``Description`` is a verbose definition of the query, e.g. “Search +for experiments and return a table.”. It will be the text that is +visible in the shortcuts section. + +The ``Query`` is the query that will be executed with the shortcut. It +adheres to the definition of the CaosDB Query Language (CQL). + +The corresponding query of our example is +``SELECT date, name FROM Experiment``. + +Advanced Shortcut +----------------- + +The basic shortcut does not allow for any parameterization. It is just a +plain string or like a bookmark. + +Advanced shortcuts use a special syntax, where text placeholders are +used to define parameters of the shortcut. The parameters can be set by +the user at the time of the execution. An example can best illustrate +what that means: + +Suppose you want to search for experiments by their year. The query for +that would be ``SELECT date, name FROM Experiment WITH date IN 2018``. + +Now, the actual year in the query can be made editable by replacing the +year ``2018`` with ``{year}``. + +The ``Description`` now must also contain this placeholder ``{year}``, +e.g. “Search for experiements conducted in year {year}”. When the +shortcut is displayed in the shortcuts section below the query input +field, the placeholder is replaced by a text input field and the user +can insert a year and execute the shortcut with the year being inserted +into the query. + +Placeholders +~~~~~~~~~~~~ + +The placeholders have simple rules. A placeholder always starts and ends +with curly brackets, like in the example ``{year}``. The text inside the +brackets (the placeholder’s *id*) may contain any combination of +alphanumeric signs (0-9,a-z,A-Z). The use of special characters like +colons, commas or the like is discouraged. They are reserved for future +extensions of the placeholders. Apart from that, you are free to choose +any placeholder *id* that seems suitable for you. + +Both components of the query shortcut (description and query) must +contain the same set of placeholders, otherwise the query shortcuts +might not work as intended. If there is a ``{year}`` in the query, there +must be a ``{year}`` in the description. + +Each placeholder *id* must occur only once in both components – if you +need to use two years in your shortcut you have to use ``{year1}`` and +``{year2}`` or any other combinations of placeholder *ids*. + +Example for global_query_shortcuts.json +--------------------------------------- + +The following example for the file global_query_shortcuts.json would create two global query shortcuts for finding experiments. The second example includes a variable for specifying the year. + +.. code-block:: json + + [ + { + "description": "Show a list of all Experiments", + "query": "FIND Record Experiment" + }, + { + "description": "Show a table of Experiments for year: {year}", + "query": "SELECT date, project, identifier FROM Record Experiment with date in {year}" + }, + ] + +Data Model for User Query Templates +----------------------------------- + +The current default data model for CaosDB does not include the RecordTypes which are needed for the user query templates. See https://gitlab.indiscale.com/caosdb/src/caosdb-webui/-/issues/104 for details. + +The solution is to create the RecordTypes, e.g. using the Python interface, as follows: + +.. code-block:: python + + datamodel = caosdb.Container() + datamodel.extend([ + caosdb.Property("Query", datatype=caosdb.TEXT), + caosdb.Property("templateDescription", datatype=caosdb.TEXT), + caosdb.RecordType( + "UserTemplate" + ).add_property("Query", importance=caosdb.OBLIGATORY + ).add_property("templateDescription", importance=caosdb.OBLIGATORY), + ]) + datamodel.insert() diff --git a/src/doc/extension/references.rst b/src/doc/extension/references.rst new file mode 100644 index 0000000000000000000000000000000000000000..63c551612e5e9d807846595b6c5e458bc5096615 --- /dev/null +++ b/src/doc/extension/references.rst @@ -0,0 +1,38 @@ +Customizing the display of referenced entities +============================================= + +CaosDB WebUI supports the customized display of referenced entities +using the :doc:`ext_references <../api/module-resolve_references>` +module. The ``BUILD_MODULE_EXT_RESOLVE_REFERENCES`` build variable has +to be set to ``ENABLED`` (see :doc:`/getting_started`) in order to use +this module. + +You may then define your own JavaScript module to define how +references to specific Records should be resolved. The module has to +be located at a directory which is known to CaosDB WebUI; we recommend +``caosdb-webui/src/ext/js``. Set the value of the +``BUILD_EXT_REFERENCES_CUSTOM_RESOLVER`` build variable to the name of +this module. The module has to have a ``resolve`` function which takes +an entity id as its only parameter and returns a ``reference_info`` +object with the resolved custom reference as a ``text`` property. So +the basic structure of the module should look like + +.. code-block:: javascript + + var my_reference_resolver = new function () { + // Has to be called ``resolve`` and has to take exactly one + // string parameter: the id of the referenced entity. + this.resolve = async function (id) { + /* + * find the string that the reference should be resolved to, + * e.g., from the value of the entity's properties. + */ + return {"text": new_reference_text} + } + } + +An example is located in +``caosdb-webui/src/ext/js/person_reference_resolver.js``. It resolves +any reference to a ``Person`` Record to the value of its ``firstname`` +and ``lastname`` properties separated by a space and is active by +default. diff --git a/src/ext/js/person_reference_resover.js b/src/ext/js/person_reference_resover.js new file mode 100644 index 0000000000000000000000000000000000000000..393557354904787f04472585bca0883d64200d86 --- /dev/null +++ b/src/ext/js/person_reference_resover.js @@ -0,0 +1,65 @@ +/* + * ** header v3.0 + * This file is a part of the CaosDB Project. + * + * Copyright (C) 2021 IndiScale GmbH (info@indiscale.com) + * Copyright (C) 2021 Florian Spreckelsen (f.spreckelsen@indiscale.com) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + * + * ** end header + */ + +/** + * @module person_reference + * + * Replace the reference to a Person Record by the values of that + * Record's firstname and lastname properties. + * + * TODO: Make name(s) of person RecordType(s) and names of firstname + * and lastname properties configurable. + */ +var person_reference = new function () { + + var logger = log.getLogger("person_reference"); + + const lastname_prop_name = "lastname" + const firstname_prop_name = "firstname" + const person_rt_name = "Person" + + /** + * Return the name of a person as firstname + lastname + */ + this.get_person_str = function (el) { + var valpr = getProperties(el); + if (valpr == undefined) { + return; + } + return valpr.filter(valprel => + valprel.name.toLowerCase().trim() == + firstname_prop_name.toLowerCase())[0].value + + " " + + valpr.filter(valprel => valprel.name.toLowerCase().trim() == + lastname_prop_name.toLowerCase())[0].value; + } + + this.resolve = async function (id) { + + const entity = (await resolve_references.retrieve(id))[0]; + + if (resolve_references.is_child(entity, person_rt_name)) { + return {"text": person_reference.get_person_str(entity)}; + } + } +} diff --git a/test/core/js/modules/ext_cosmetics.js.js b/test/core/js/modules/ext_cosmetics.js.js new file mode 100644 index 0000000000000000000000000000000000000000..d5d4df7f10a2859bcd7318680d4f6720aedc6127 --- /dev/null +++ b/test/core/js/modules/ext_cosmetics.js.js @@ -0,0 +1,87 @@ +/* + * This file is a part of the CaosDB Project. + * + * Copyright (C) 2021 IndiScale GmbH <info@indiscale.com> + * Copyright (C) 2021 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/>. + */ + +'use strict'; + +QUnit.module("ext_cosmetics.js", { + before: function (assert) { + cosmetics.init(); + // setup before module + }, + beforeEach: function (assert) { + // setup before each test + }, + afterEach: function (assert) { + // teardown after each test + }, + after: function (assert) { + // teardown after module + } +}); + +QUnit.test("linkify - https", function (assert) { + assert.ok(cosmetics.linkify, "linkify available"); + var test_cases = [ + ["https://link", 1], + ["this is other text https://link", 1], + ["https://link this is other text", 1], + ["this is other text https://link and this as well", 1], + ["this is other text https://link", 1], + ["this is other text https://link and here comes another link https://link and more text", 2], + ]; + for (let test_case of test_cases) { + var text_value = $(`<div class="caosdb-f-property-text-value">${test_case[0]}</div>`); + $(document.body).append(text_value); + assert.equal($(text_value).find("a[href='https://link']").length, 0, "no link present"); + cosmetics.linkify(); + assert.equal($(text_value).find("a[href='https://link']").length, test_case[1], "link is present"); + text_value.remove(); + } +}); + +QUnit.test("linkify - http", function (assert) { + var test_cases = [ + ["http://link", 1], + ["this is other text http://link", 1], + ["http://link this is other text", 1], + ["this is other text http://link and this as well", 1], + ["this is other text http://link", 1], + ["this is other text http://link and here comes another link http://link and more text", 2], + ]; + for (let test_case of test_cases) { + var text_value = $(`<div class="caosdb-f-property-text-value">${test_case[0]}</div>`); + $(document.body).append(text_value); + assert.equal($(text_value).find("a[href='http://link']").length, 0, "no link present"); + cosmetics.linkify(); + assert.equal($(text_value).find("a[href='http://link']").length, test_case[1], "link is present"); + text_value.remove(); + } +}); + +QUnit.test("linkify cut-off (40)", function (assert) { + var test_case = "here is some text https://this.is.a.link/with/more/than/40/characters/ this is more text"; + var text_value = $(`<div class="caosdb-f-property-text-value">${test_case}</div>`); + $(document.body).append(text_value); + assert.equal($(text_value).find("a").length, 0, "no link present"); + cosmetics.linkify(); + assert.equal($(text_value).find("a").length, 1, "link is present"); + assert.equal($(text_value).find("a").text(), "https://this.is.a.link/with/more/th[...] ", "link text has been cut off"); + text_value.remove(); +}); \ No newline at end of file diff --git a/test/core/js/modules/ext_qrcode.js.js b/test/core/js/modules/ext_qrcode.js.js new file mode 100644 index 0000000000000000000000000000000000000000..d4d505913035d17d14cb7b110e8dc67b0a018a44 --- /dev/null +++ b/test/core/js/modules/ext_qrcode.js.js @@ -0,0 +1,101 @@ +/* + * This file is a part of the CaosDB Project. + * + * Copyright (C) 2021 IndiScale GmbH <info@indiscale.com> + * Copyright (C) 2021 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/>. + */ + +'use strict'; + +QUnit.module("ext_qrcode.js", { + before: function (assert) { + // setup before module + }, + beforeEach: function (assert) { + // setup before each test + $(document.body).append('<div data-entity-id="eid123" data-version-id="vid234" id="ext-qrcode-test-entity" class="caosdb-entity-panel"><div class="caosdb-v-entity-header-buttons-list"></div></div>'); + }, + afterEach: function (assert) { + // teardown after each test + const modal = bootstrap.Modal.getInstance($(".modal")[0]); + if (modal) modal.dispose(); + $("#ext-qrcode-test-entity").remove(); + $(".modal").remove(); + }, + after: function (assert) { + // teardown after module + } +}); + +QUnit.test("init", function (assert) { + assert.ok(ext_qrcode.init, "init available"); + assert.equal($(".caosdb-f-entity-qrcode-button").length, 0, "no button before."); + ext_qrcode.init(); + assert.equal($(".caosdb-f-entity-qrcode-button").length, 1, "button has been added."); + ext_qrcode.init(); + assert.equal($(".caosdb-f-entity-qrcode-button").length, 1, "still only one button."); + + ext_qrcode.remove_qrcode_button($("#ext-qrcode-test-entity")[0]); + assert.equal($(".caosdb-f-entity-qrcode-button").length, 0, "no button after removal."); +}); + +QUnit.test("create_qrcode_button", function (assert) { + assert.equal(ext_qrcode.create_qrcode_button("entityid", "versionid").tagName, "BUTTON", "create_qrcode_button creates a button"); +}); + + +QUnit.test("qrcode_button_click_handler", function (assert) { + var done = assert.async(); + assert.equal($("#qrcode-modal-entityid-versionid").length, 0, "no modal before first click"); + ext_qrcode.qrcode_button_click_handler("entityid", "versionid") + $("#qrcode-modal-entityid-versionid").on("shown.bs.modal", done); + assert.equal($("#qrcode-modal-entityid-versionid").length, 1, "first click added the modal"); +}); + +QUnit.test("update_qrcode", async function (assert) { + // create modal + const entity_id = "eid456"; + const entity_version = "vid3564"; + const modal_id = `qrcode-modal-${entity_id}-${entity_version}`; + const modal_element = ext_qrcode.create_qrcode_modal(modal_id, entity_id, entity_version); + $(document.body).append(modal_element); + + assert.equal($("#" + modal_id).find(".caosdb-f-entity-qrcode *").length, 0, "no qrcode."); + assert.equal($("#" + modal_id).find(".caosdb-f-entity-qrcode-link *").length, 0, "no link."); + + // update adds qrcode + ext_qrcode.update_qrcode(modal_element, entity_id, entity_version); + + assert.equal($("#" + modal_id).find(".caosdb-f-entity-qrcode-link a")[0].href, connection.getEntityUri([entity_id]), "link points to entity head."); + // wait until qrcode is ready + await sleep(500); + assert.equal($("#" + modal_id).find(".caosdb-f-entity-qrcode canvas").length, 1, "qrcode is there."); + + $("#" + modal_id).find("canvas").remove(); + assert.equal($("#" + modal_id).find(".caosdb-f-entity-qrcode canvas").length, 0, "removed qrcode canvas for next test."); + // select radio button for link to exact version: check both... + $("#" + modal_id).find("input[name=entity-qrcode-versioned]").prop("checked", true); + // ...then uncheck first + $("#" + modal_id).find("input[name=entity-qrcode-versioned]").first().prop("checked", false); + $("#" + modal_id).find("form").trigger("change"); + + // check: uri has changed + assert.equal($("#" + modal_id).find(".caosdb-f-entity-qrcode-link a")[0].href, connection.getEntityUri([entity_id]) + "@" + entity_version, "link changed to versioned entity."); + // wait until qrcode is ready + await sleep(500); + assert.equal($("#" + modal_id).find(".caosdb-f-entity-qrcode canvas").length, 1, "qrcode there again."); + +}); \ No newline at end of file diff --git a/test/core/js/modules/ext_references.js.js b/test/core/js/modules/ext_references.js.js index 43cc1ddd742d6702232b740bbfd96411f41b08f5..54e06d33d5f1c33781efe11802a7fbfc5ba44d89 100644 --- a/test/core/js/modules/ext_references.js.js +++ b/test/core/js/modules/ext_references.js.js @@ -104,7 +104,7 @@ QUnit.test("is_child", function(assert){ }); QUnit.test("get_person_str", function(assert){ - assert.ok(resolve_references.get_person_str); + assert.ok(person_reference.get_person_str); }); QUnit.test("update_visible_references_without_summary", async function(assert){ diff --git a/test/core/js/modules/webcaosdb.js.js b/test/core/js/modules/webcaosdb.js.js index 52bf4ada52d2ce59b59d8615c89ca5796343622b..ad26e8455fb7ca16a9e6e9687606b6aba1d0e7e6 100644 --- a/test/core/js/modules/webcaosdb.js.js +++ b/test/core/js/modules/webcaosdb.js.js @@ -91,28 +91,6 @@ QUnit.test("injectTemplate", async function (assert) { assert.equal(xml2str(result_xml), "<newroot xmlns=\"http://www.w3.org/1999/xhtml\">content</newroot>"); }); -QUnit.test("getEntityId", function (assert) { - assert.ok(getEntityId, "function available"); - let okElem = $('<div><div class="caosdb-id">1234</div></div>')[0]; - let notOkElem = $('<div><div class="caosdb-id">asdf</div></div>')[0]; - let emptyElem = $('<div></div>')[0]; - - assert.throws(() => { - getEntityId(); - }, "no parameter throws"); - assert.throws(() => { - getEntityId(null); - }, "null parameter throws"); - assert.throws(() => { - getEntityId(notOkElem); - }, "on-integer ID throws"); - assert.throws(() => { - getEntityId(empty); - }, "empty elem throws"); - - assert.equal("1234", getEntityId(okElem), "ID found"); -}); - QUnit.test("asyncXslt", function (assert) { let xml_str = '<root/>'; let xsl_str = '<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"><xsl:output method="html" /><xsl:template match="root"><newroot/></xsl:template></xsl:stylesheet>'; @@ -920,7 +898,7 @@ QUnit.test("createCarouselNav", function (assert) { assert.equal($(carousel).find("." + preview.classNamePreviewCarouselNav).length, 1, "carousel has nav"); assert.equal($(carousel).find(".carousel-inner").length, 1, "carousel has inner"); for (let i = 0; i < correct_order_id.length; i++) { - assert.equal(getEntityId($(carousel).find('.carousel-item')[i]), correct_order_id[i], "entities ids are in order") + assert.equal(getEntityID($(carousel).find('.carousel-item')[i]), correct_order_id[i], "entities ids are in order") } assert.ok(carousel.id, "has id"); diff --git a/test/docker/Dockerfile b/test/docker/Dockerfile index f90db2f9b2b869e12f52de595b7a7677f52374b5..2880d864bc7df86bf7cade4d6c232b387e513bfd 100644 --- a/test/docker/Dockerfile +++ b/test/docker/Dockerfile @@ -9,6 +9,8 @@ RUN apt-get update \ && apt-get install -y \ firefox-esr gettext-base python3-pip \ python3-httpbin git curl x11-apps xvfb unzip \ + libhdf5-dev \ + pkgconf \ nodejs # Don't install `npm` (Debian), it conflicts with the `nodejs` (Node) package \ && apt-get install -f