diff --git a/.gitignore b/.gitignore index 9f83a53d778bb59be1c3a32559f6e4a504b97137..d2bd7089a35ed5496464734e2026397e3a802baa 100644 --- a/.gitignore +++ b/.gitignore @@ -29,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..ae5c058e15f82a98df92e6822efe174fe54edece 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,51 @@ # 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). +## [0.4.1] - 2021-11-04 + +### Added (for new features, dependecies etc.) + +* `form_panel` module for conveniently creating a panel for web forms. + +### Changed (for changes in existing functionality) + +### Deprecated (for soon-to-be removed features) + +### Removed (for now removed features) + +### Fixed (for any bug fixes) + +### Security (in case of vulnerabilities) + +### Documentation (for notable additions or changes of the documentation) + +## [0.4.0] - 2021-10-28 + +### 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). +* Optional functionality to bookmark all query results. Note that too many + bookmarks will result in the URI being too lang and bookmarks will have to be + cleared manually. + +### 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 +53,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 +69,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/DEPENDENCIES.md b/DEPENDENCIES.md index d3dca09ef0ddd2c4d0d33f0134305a9c52691f09..c6043d09d049b91cfc0a03560970537feb8bbc06 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -1,2 +1,28 @@ -* CaosDB Server == 0.4 +* CaosDB Server 0.5.x * Make 4.2.0 + +# Java Script Libraries (included in this repository) +* bootstrap-5.0.1 +* bootstrap-autocomplete-2.3.5 +* bootstrap-icons-1.4.1 +* bootstrap-select-1.14.0-beta2 +* dropzone-5.5.0 +* javascript-state-machine-master +* jquery-3.6.0.min.js +* loglevel-1.6.4 +* qrcode-1.4.4 +* showdown-1.8.6 +* plotly.js-1.52.2 +* UTIF-8205c1f + +## For the map + +* leaflet-1.5.1 +* Leaflet.Coordinates-0.1.5 +* leaflet.latlng-graticule-20191007 +* L.Graticule.js https://github.com/turban/Leaflet.Graticule/blob/e9146fbea59ce1b0ada4ea2a012087f9a1a12473/L.Graticule.js +* proj4js-2.5.0 +* Proj4Leaflet-1.0.1 + +## For testing +* qunit-2.9.2 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..df68f1012cd71b49538b073a4c9d17e85b2214e4 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 @@ -50,11 +49,17 @@ BUILD_MODULE_EXT_BOTTOM_LINE=ENABLED BUILD_MODULE_EXT_BOTTOM_LINE_TABLE_PREVIEW=DISABLED BUILD_MODULE_EXT_BOTTOM_LINE_TIFF_PREVIEW=DISABLED BUILD_MODULE_EXT_BOOKMARKS=ENABLED +BUILD_MODULE_EXT_ADD_QUERY_TO_BOOKMARKS=DISABLED 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 +153,8 @@ MODULE_DEPENDENCIES=( ext_sss_markdown.js ext_trigger_crawler_form.js ext_bookmarks.js + ext_cosmetics.js + qrcode.js + ext_qrcode.js + form_panel.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/js/ext_bookmarks.js b/src/core/js/ext_bookmarks.js index d99ff362d8a26ee4818a76a624c80f55f757c419..d9da463699ff7021b9f94a877aa3d9379dffb5dc 100644 --- a/src/core/js/ext_bookmarks.js +++ b/src/core/js/ext_bookmarks.js @@ -2,18 +2,19 @@ * ** header v3.0 * This file is a part of the CaosDB Project. * - * Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> + * Copyright (C) 2020,2021 IndiScale GmbH <info@indiscale.com> * Copyright (C) 2020 Timm Fitschen <t.fitschen@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 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. + * 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/>. @@ -231,7 +232,7 @@ var ext_bookmarks = function ($, logger, config) { */ const get_export_table = async function (bookmarks, preamble, tab, newline, leading_comments) { // TODO merge with related code in the module "caosdb_table_export". - preamble = ((typeof preamble == 'undefined') ? "data:text/csv;charset=utf-8,": preamble); + preamble = ((typeof preamble == 'undefined') ? "data:text/csv;charset=utf-8," : preamble); tab = tab || "%09"; newline = newline || "%0A"; leading_comments = (leading_comments ? leading_comments.join(newline) + newline : ""); @@ -489,10 +490,8 @@ var ext_bookmarks = function ($, logger, config) { counter = 0; update_collection([]); - // reset all buttons - get_bookmark_buttons().forEach((x) => { - set_button_state(x, false); - }); + // re-init to reset all buttons + init(); const storage_key_prefix = get_collection_prefix() remove_from_storage_by_prefix(storage_key_prefix); @@ -566,6 +565,78 @@ var ext_bookmarks = function ($, logger, config) { collection_id = id; } + /** + * Add a button to add all query results to bookmarks. + */ + const add_add_query_results_button = function () { + const row_id = "caosdb-f-add-query-to-bookmarks-row" + // do nothing if already existing + if ($("#" + row_id).length > 0) { + return; + } + + // do nothing if no results + if ($(".caosdb-query-response-results").text().trim() == "0") { + return; + } + + const button_html = $(`<div class="text-end" id=${row_id}> + <button class="btn btn-link" onclick="ext_bookmarks.add_query_results_to_bookmarks();">Bookmark all query results</button> +</div>`)[0]; + + // Add to query results box + $(".caosdb-query-response-heading").append(button_html); + } + + /** + * Execute select query and add all new ids to bookmarks. + */ + const add_query_results_to_bookmarks = async function () { + + const query_string = get_query_from_response(); + const waiting_notification = createWaitingNotification( + "Adding results to bookmarks. Please wait and do not reload the page."); + const bookmarks_row = $("#caosdb-f-add-query-to-bookmarks-row"); + bookmarks_row.find("button").hide(); + bookmarks_row.append(waiting_notification); + const resp = await query(query_string); + for (const eid of resp) { + bookmark_storage.setItem(get_key(getEntityID(eid)), getEntityID(eid)); + } + // re-init for correct display of counter and entities on page + init(); + removeAllWaitingNotifications(bookmarks_row); + bookmarks_row.find("button").prop("disabled", true).show(); + } + + /** + * Transform a given query it to a "SELECT ID FROM ..." query. + * + * @param {string} query_string + */ + const get_select_id_query_string = function (query_string) { + const test_string = query_string.toLowerCase(); + const select_string = "SELECT ID FROM "; + + // Will only be called on valid query results, so don't have to check + // for invalid query strings. + if (test_string.startsWith("find") || test_string.startsWith("count")) { + return select_string + query_string.slice(query_string.indexOf(" ") + 1); + } + if (test_string.startsWith("select")) { + return select_string + query_string.slice(test_string.indexOf("from ") + 5); + } + } + + /** + * Return the SELECT query created from the contents of the query response field + */ + const get_query_from_response = function () { + + const orig_query = $(".caosdb-f-query-response-string")[0].innerText; + return get_select_id_query_string(orig_query.trim()); + } + /** * Initialize this module. */ @@ -591,6 +662,10 @@ var ext_bookmarks = function ($, logger, config) { init_bookmark_buttons(e.target); }, true); } + + if ("${BUILD_MODULE_EXT_ADD_QUERY_TO_BOOKMARKS}" == "ENABLED") { + add_add_query_results_button(); + } } /** @@ -625,7 +700,6 @@ var ext_bookmarks = function ($, logger, config) { if (data_getters[data_key]) { uncached = (await data_getters[data_key](id)) } - // don't cache if getting the information is trivial or there are other // reasons why this is in the data_no_cache array. if (data_no_cache.indexOf(data_key) == -1) { @@ -652,6 +726,9 @@ var ext_bookmarks = function ($, logger, config) { get_bookmark_buttons: get_bookmark_buttons, init_button: init_button, get_bookmark_data: get_bookmark_data, + get_select_id_query_string: get_select_id_query_string, + get_query_from_response: get_query_from_response, + add_query_results_to_bookmarks: add_query_results_to_bookmarks, } }; @@ -666,10 +743,10 @@ $(document).ready(function () { // from the server. const get_path = async function (id) { if (id.indexOf("@") > -1) { - const entity = $(`[data-bmval='${id}']`); - if (entity.length > 0) { - return getEntityPath(entity[0]) || ""; - } + const entity = $(`[data-bmval='${id}']`); + if (entity.length > 0) { + return getEntityPath(entity[0]) || ""; + } } return $(await transaction.retrieveEntityById(id)).attr("path"); } @@ -681,20 +758,20 @@ $(document).ready(function () { const get_name = async function (id) { if (id.indexOf("@") > -1) { - const entity = $(`[data-bmval='${id}']`); - if (entity.length > 0) { - return getEntityName(entity[0]) || ""; - } + const entity = $(`[data-bmval='${id}']`); + if (entity.length > 0) { + return getEntityName(entity[0]) || ""; + } } return $(await transaction.retrieveEntityById(id)).attr("name"); } const get_rt = async function (id) { if (id.indexOf("@") > -1) { - const entity = $(`[data-bmval='${id}']`); - if (entity.length > 0) { - return getParents(entity[0]).join("/"); - } + const entity = $(`[data-bmval='${id}']`); + if (entity.length > 0) { + return getParents(entity[0]).join("/"); + } } const parent_names = $(await transaction.retrieveEntityById(id)) .find("Parent").toArray().map(x => x.getAttribute("name")) @@ -727,4 +804,4 @@ $(document).ready(function () { ext_bookmarks = ext_bookmarks($, log.getLogger("ext_bookmarks"), config); caosdb_modules.register(ext_bookmarks); } -}); +}); \ No newline at end of file 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/form_elements.js b/src/core/js/form_elements.js index d01b45ee9148febc592d615a8fa947d0d53656d3..193235a2f8a799c07ccc893742d5df9a7d0fa7d1 100644 --- a/src/core/js/form_elements.js +++ b/src/core/js/form_elements.js @@ -100,14 +100,41 @@ var form_elements = new function () { this.version = "0.1"; this.dependencies = ["log", "caosdb_utils", "markdown", "bootstrap"]; this.logger = log.getLogger("form_elements"); + /** + * Event. On form cancel. + */ this.cancel_form_event = new Event("caosdb.form.cancel"); + /** + * Event. On form submit. + */ this.submit_form_event = new Event("caosdb.form.submit"); + /** + * Event. On field change. + */ this.field_changed_event = new Event("caosdb.field.changed"); + /** + * Event. On field enabled. + */ this.field_enabled_event = new Event("caosdb.field.enabled"); + /** + * Event. On field disabled. + */ this.field_disabled_event = new Event("caosdb.field.disabled"); + /** + * Event. On field ready (e.g. for reference drop downs) + */ this.field_ready_event = new Event("caosdb.field.ready"); + /** + * Event. On field error (e.g. for reference drop downs) + */ this.field_error_event = new Event("caosdb.field.error"); + /** + * Event. Form submitted successfully. + */ this.form_success_event = new Event("caosdb.form.success"); + /** + * Event. Error after form was submitted. + */ this.form_error_event = new Event("caosdb.form.error"); @@ -1266,7 +1293,10 @@ var form_elements = new function () { }, config.to); const from_input = this.make_form_field(from_config); + $(from_input).toggleClass("form-control", false); + const to_input = this.make_form_field(to_config); + $(to_input).toggleClass("form-control", false); const ret = $(this._make_field_wrapper(config.name)); if (config.label) { @@ -1277,12 +1307,8 @@ var form_elements = new function () { ret.append(to_input); // styling - $(from_input).toggleClass("form-control", false); - $(from_input).find(".col-sm-3").toggleClass("col-sm-3", false).toggleClass("col-sm-1"); - $(from_input).find(".col-sm-9").toggleClass("col-sm-9", false).toggleClass("col-sm-3"); - $(to_input).toggleClass("form-control", false); - $(to_input).find(".col-sm-3").toggleClass("col-sm-3", false).toggleClass("col-sm-1").toggleClass("col-sm-offset-1"); - $(to_input).find(".col-sm-9").toggleClass("col-sm-9", false).toggleClass("col-sm-3"); + $(from_input).toggleClass("col-sm-4", true); + $(to_input).toggleClass("col-sm-4", true); return ret[0]; } diff --git a/src/core/js/form_panel.js b/src/core/js/form_panel.js new file mode 100644 index 0000000000000000000000000000000000000000..9728a4ccea54c36d85399a3148373b7372108db0 --- /dev/null +++ b/src/core/js/form_panel.js @@ -0,0 +1,94 @@ +/* + * ** header v3.0 + * 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/>. + * + * ** end header + */ + +'use strict'; + +/** + * form_panel module for creating a panel below the navbar where forms can be + * placed. + */ +var form_panel = new function () { + const logger = log.getLogger("form_panel"); + this.version = "0.1"; + this.dependencies = ["log", "caosdb_utils", "markdown", "bootstrap"]; + + /** + * Return a the panel which shall contain the form. + * + * Side-effects: + * 1. Creates the form panel if it does not exist. + * 2. Removes the welcome panel if present. + */ + this.get_form_panel = function (panel_id, title) { + // remove welcome + $(".caosdb-f-welcome-panel").remove(); + $(".caosdb-v-welcome-panel").remove(); + + var existing = $("#" + panel_id); + if (existing.length > 0) { + return existing[0]; + } + const panel = $('<div id="' + panel_id + '" class="caosdb-f-form-panel bg-light container mb-1"/>'); + const header = $('<h2 class="text-center">' + title + '</h2>'); + panel.append(header); + + // add to main panel + $('nav').after(panel); + + return panel[0]; + }; + + /** + * Remove the form panel from the DOM tree. + */ + this.destroy_form_panel = function (panel) { + $(panel).remove(); + }; + + /** + * Creates a callback function that toggles the form panel which + */ + this.create_show_form_callback = function (panel_id, title, form_config) { + return (e) => { + logger.trace("enter show_form_panel", e); + + const panel = $(form_panel.get_form_panel(panel_id, title)); + if (panel.find("form").length === 0) { + const form = form_elements.make_form(form_config); + panel.append(form); + $(form).find(".selectpicker").selectpicker(); + + form.addEventListener("caosdb.form.cancel", + (e) => form_panel.destroy_form_panel(panel), + true + ); + } + } + }; + + this.init = function () { + } +} + +$(document).ready(function () { + caosdb_modules.register(form_panel); +}); diff --git a/src/core/js/webcaosdb.js b/src/core/js/webcaosdb.js index 74dc62be15551f987707253ca201777f7c3929ac..efa28c9b39921df3e02a25ec95d4601f752fa288 100644 --- a/src/core/js/webcaosdb.js +++ b/src/core/js/webcaosdb.js @@ -164,19 +164,19 @@ this.navbar = new function () { // show form and hide the show_button const _in = () => { - // xs means viewport <= 768px - form.removeClass("d-none"); - form.addClass("d-xs-inline-block"); - show_button.removeClass("d-inline-block"); - show_button.addClass("d-none"); + // xs means viewport <= 768px + form.removeClass("d-none"); + form.addClass("d-xs-inline-block"); + show_button.removeClass("d-inline-block"); + show_button.addClass("d-none"); } // hide form and show the show_button const _out = () => { - // xs means viewport <= 768px - form.removeClass("d-xs-inline-block"); - form.addClass("d-none"); - show_button.removeClass("d-none"); - show_button.addClass("d-inline-block"); + // xs means viewport <= 768px + form.removeClass("d-xs-inline-block"); + form.addClass("d-none"); + show_button.removeClass("d-none"); + show_button.addClass("d-inline-block"); } show_button.on("click", () => { // show form... @@ -607,7 +607,7 @@ this.transformation = new function () { * @return {XMLDocument} xslt script */ this.retrieveEntityXsl = async function _rEX(root_template) { - const _root = root_template || '<xsl:template match="/" xmlns="http://www.w3.org/1999/xhtml"><div class="root"><xsl:apply-templates select="Response/*" mode="entities"/></div></xsl:template>'; + const _root = root_template || '<xsl:template match="/" xmlns="http://www.w3.org/1999/xhtml"><div class="root"><xsl:apply-templates select="Response/child::*" mode="entities"/></div></xsl:template>'; var entityXsl = await transformation.retrieveXsltScript("entity.xsl"); var commonXsl = await transformation.retrieveXsltScript("common.xsl"); var errorXsl = await transformation.retrieveXsltScript('messages.xsl'); @@ -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 => { @@ -1313,7 +1313,7 @@ var queryForm = new function () { var submithandler = function () { // store current query var queryField = form.query; - var value = queryField.value.toUpperCase(); + var value = queryField.value.toUpperCase().trim(); if (typeof value == "undefined" || value.length == 0) { return; } @@ -1327,7 +1327,7 @@ var queryForm = new function () { paging = form.P.value } - queryForm.redirect(queryField.value, paging); + queryForm.redirect(queryField.value.trim(), paging); }; $("#caosdb-query-textarea").on("keydown", (e) => { @@ -1507,10 +1507,12 @@ function createErrorNotification(msg) { * Create a waiting notification with a informative message for the waiting user. * * @param {String} info, a message for the user + * @param {String} id, optional, the id of the message div. Default is empty * @return {HTMLElement} A div with class `caosdb-preview-waiting-notification`. */ -function createWaitingNotification(info) { - return $('<div class="' + globalClassNames.WaitingNotification + '">' + info + '</div>')[0]; +function createWaitingNotification(info, id) { + id = id ? `id="${id}"` : ""; + return $(`<div class="${globalClassNames.WaitingNotification}" ${id}>${info}</div>`)[0]; } /** @@ -1520,23 +1522,10 @@ function createWaitingNotification(info) { * @return {HTMLElement} The parameter `elem`. */ function removeAllWaitingNotifications(elem) { - $(elem.getElementsByClassName(globalClassNames.WaitingNotification)).remove(); + $(elem).find(`.${globalClassNames.WaitingNotification}`).remove(); 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 @@ -1938,4 +1927,4 @@ class _CaosDBModules { var caosdb_modules = new _CaosDBModules() -$(document).ready(initOnDocumentReady); +$(document).ready(initOnDocumentReady); \ No newline at end of file diff --git a/src/core/xsl/entity.xsl b/src/core/xsl/entity.xsl index d562bf99f7611b976e10d5349d0c99972d6d8fdd..92f08ea70645a8282f97f364c9fc4143f37afd6a 100644 --- a/src/core/xsl/entity.xsl +++ b/src/core/xsl/entity.xsl @@ -25,6 +25,10 @@ <xsl:output method="html"/> <!-- These little colored Rs, RTs, Ps, and Fs which hilite the beginning of a new entity. --> + <xsl:template match="Entity" mode="entity-heading-label"> + <span class="badge caosdb-f-entity-role caosdb-label-entity me-1" + title="This is an entity. The role is not specified.">E</span> + </xsl:template> <xsl:template match="Property" mode="entity-heading-label"> <span class="badge caosdb-f-entity-role caosdb-label-property me-1" data-entity-role="Property" title="This entity is a Property.">P</span> @@ -98,7 +102,7 @@ </div> </xsl:template> <!-- Main entry for ENTITIES --> - <xsl:template match="Property|Record|RecordType|File" mode="entities"> + <xsl:template match="Property|Record|RecordType|File|Response/Entity" mode="entities"> <div class="card caosdb-entity-panel mb-2"> <xsl:apply-templates select="Version" mode="entity-version-marker"/> <xsl:attribute name="id"> 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/src/core/xsl/query.xsl b/src/core/xsl/query.xsl index 2b647c07bebe7f7cd72198baf27e45318e25a18e..702a390f28ada96e140f40f10218b1740ab10700 100644 --- a/src/core/xsl/query.xsl +++ b/src/core/xsl/query.xsl @@ -56,7 +56,9 @@ <div class="col-sm-10 caosdb-overflow-box"> <div class="caosdb-overflow-content"> <span>Query: </span> - <xsl:value-of select="@string"/> + <span class="caosdb-f-query-response-string"> + <xsl:value-of select="@string"/> + </span> </div> </div> <div class="col-sm-2 text-end"> @@ -223,15 +225,28 @@ <xsl:value-of select="$field-name"/> </xsl:attribute> <div class="caosdb-f-property-value caosdb-v-property-value"> - <xsl:apply-templates select="/Response/*[@id=$entity-id and Version/@id=$version-id]" mode="walk-select-segments"> - <!--<xsl:apply-templates select="/Response/*[@id=$entity-id]" mode="walk-select-segments">--> - <xsl:with-param name="first-segment"> - <xsl:value-of select="substring-before(concat($field-name, '.'), '.')"/> - </xsl:with-param> - <xsl:with-param name="next-segments"> - <xsl:value-of select="substring-after($field-name, '.')"/> - </xsl:with-param> - </xsl:apply-templates> + <xsl:choose> + <xsl:when test="$version-id!=''"> + <xsl:apply-templates select="/Response/*[@id=$entity-id and Version/@id=$version-id]" mode="walk-select-segments"> + <xsl:with-param name="first-segment"> + <xsl:value-of select="substring-before(concat($field-name, '.'), '.')"/> + </xsl:with-param> + <xsl:with-param name="next-segments"> + <xsl:value-of select="substring-after($field-name, '.')"/> + </xsl:with-param> + </xsl:apply-templates> + </xsl:when> + <xsl:otherwise> + <xsl:apply-templates select="/Response/*[@id=$entity-id]" mode="walk-select-segments"> + <xsl:with-param name="first-segment"> + <xsl:value-of select="substring-before(concat($field-name, '.'), '.')"/> + </xsl:with-param> + <xsl:with-param name="next-segments"> + <xsl:value-of select="substring-after($field-name, '.')"/> + </xsl:with-param> + </xsl:apply-templates> + </xsl:otherwise> + </xsl:choose> </div> </td> </xsl:template> diff --git a/src/doc/extension/forms.rst b/src/doc/extension/forms.rst index 1bced612b5f9517c5ec149871cbf53321b4671d4..e1891b8d7e571c4a273cec98fcc39e50398936f0 100644 --- a/src/doc/extension/forms.rst +++ b/src/doc/extension/forms.rst @@ -45,6 +45,36 @@ On submission, the function ``my_special_submit_handler`` is being called with t As the generated form is a plain HTML form, the javascript form API can be used. However, there are special methods in the ``form_elements`` module e.g. :doc:`get_fields <../api/module-form_elements>` which are especially designed to interact with the forms generated by the ``make_form`` factory. + +Placing the form in a panel below the navbar +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +There are functions in the `form_panel` module to make it easy to place forms at the typical location: +below the navbar. The following shows how the config (see above) is passed to +`init_show_form_panel_button` a direct call to `make_form` is no longer necessary. + +.. code-block:: javascript + + const title = "Upload CSV File"; // title of the form and text in the toolbox + const panel_id = "csv_upload_form_panel"; + + /** + * Add a button to the navbar, saying "Upload CSV File" which opens a + * form for file upload. + */ + const init_show_form_panel_button = function () { + navbar.add_tool(title, tool_box, { + callback: form_panel.create_show_form_callback( + panel_id, + title, + csv_form_config) + }); + }; + + const init = function () { + init_show_form_panel_button(); + } + Calling a server-side script ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 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/module.md b/src/doc/extension/module.md index 267542063eaf4f03163e0fd135867390f23a8b65..486b913584c754e4c481a62c1d3c9ae14415948e 100644 --- a/src/doc/extension/module.md +++ b/src/doc/extension/module.md @@ -4,7 +4,14 @@ The CaosDB WebUI is organized in modules which can easily be added and on a modu There are a few steps necessary to create a new module. ## Create the module file -Create a new file in `src/core/js` starting with `ext_`. E.g. `ext_flight_preview.js`. This file should define one function that wraps every thing and which is enabled at the bottom of the file: + +Create a new file for each new module. We have the convention, that extensions +which are optional and should stay that way and also custom extensions for +special purposes to name the file starting with `ext_`. E.g. +`ext_flight_preview.js`. + +This file should define one function that wraps every thing and which is +enabled at the bottom of the file: ```js /* @@ -23,16 +30,14 @@ Create a new file in `src/core/js` starting with `ext_`. E.g. `ext_flight_previe * @requires somelibrary * (pass the dependencies as arguments) */ -var ext_flight_preview = function (somelibrary) { +const ext_flight_preview = function (libA, libB) { - var init = function (toolbox) { + const init = function () { /* initialization of the module */ } - /** - * doc string - */ - var some_function = function (arg1, arg2) { + /* doc string */ + const some_function = function (arg1, arg2) { } /* the main function must return the initialization of the module */ @@ -40,7 +45,7 @@ var ext_flight_preview = function (somelibrary) { init: init, }; //pass the dependencies as arguments here as well -}(somelibrary); +}(libA, libB); // this will be replaced by require.js in the future. $(document).ready(function() { @@ -52,22 +57,39 @@ $(document).ready(function() { } }); ``` -## Update xml -Add a section to `src/core/xsl/main.xsl` to include your new file. - -```xsl -<xsl:element name="script"> - <xsl:attribute name="src"> - <xsl:value-of select="concat($basepath,'webinterface/${BUILD_NUMBER}/js/ext_sss_markdown.js')"/> - </xsl:attribute> -</xsl:element> -``` -## Add to index.html in test -If you have unittests (and you should), you need to add a line in : -`test/core/index.html`. +## Install the module + +The new new file should be placed in `src/core/js` if it is intended to be merged into the main repository eventually. For development purposes and for custom extensions which are not to be published you may place it in `src/ext/js`. + +Everything inside `src/core/js` and `src/ext/js` will eventually being loaded. +So, if there are no other modules which depend on this particular new module, +you are done. + +Otherwise, when we need to configure the order in which the +module is being loaded. + + +### Dependency order + +#### For Upstream Code + +For modules which are about to be merged into the main or dev branch of this +repository, add the module's file to `build.properties.d/00_default.properties` +at the right location in the list of module files (Array +`MODULE_DEPENDENCIES`). The list defines the order in which module files are +being loaded. + +#### For Custom Extensions + +For modules which will not be published and merged with the main repository you +may append all your module files in the desired order to the +`MODULE_DEPENDENCIES` array in a new `*.properties` file (e.g. +`build.properties.d/99_local_stuff`): + + MODULE_DEPENDENCIES+=(libA.js libB.js ext_flight_preview.js) -## Update the changelog +In this example, `libA.js`, `libB.js` and `ext_flight_preview.js` are custom modules developed for this particular CaosDB webui instance. Briefly describe the changes you made to the whole repository in the file called `CHANGELOD.md` in caosdb-webui. @@ -80,4 +102,4 @@ Push your branch upstream using e.g.: Login to gitlab and navigate to the project caosdb-webui. -There should be a button titled `Create Merge Request`. \ No newline at end of file +There should be a button titled `Create Merge Request`. 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/entity.xsl.js b/test/core/js/modules/entity.xsl.js index bbc1cac0c7eff6bb06c6826c6efb7054827e82c3..e5ff1e8700b8349cddbdda0a0b52ffef47e4e75f 100644 --- a/test/core/js/modules/entity.xsl.js +++ b/test/core/js/modules/entity.xsl.js @@ -317,7 +317,7 @@ function applyTemplates(xml, xsl, mode, select = "*") { return xslt(xml, modXsl); } -function callTemplate(xsl, template, params, wrap_call) { +function callTemplate(xsl, template, params, wrap_call, root) { let entryRuleStart = '<xsl:call-template name="' + template + '">'; let entryRuleEnd = '</xsl:call-template>'; var entryRule = entryRuleStart; @@ -331,5 +331,6 @@ function callTemplate(xsl, template, params, wrap_call) { entryRule = '<xsl:template xmlns="http://www.w3.org/1999/xhtml" priority="9" match="/">' + entryRule + '</xsl:template>'; let modXsl = injectTemplate(xsl, entryRule); - return xslt(str2xml('<root/>'), modXsl); + root = root || '<root/>'; + return xslt(str2xml(root), modXsl); } diff --git a/test/core/js/modules/ext_bookmarks.js.js b/test/core/js/modules/ext_bookmarks.js.js index 831df74231e479d5b4550524f6bc0da617c98fb3..8130fe422a5bd124ef1c6767708b036e2e904f53 100644 --- a/test/core/js/modules/ext_bookmarks.js.js +++ b/test/core/js/modules/ext_bookmarks.js.js @@ -42,16 +42,18 @@ QUnit.module("ext_bookmarks.js", { } }); -QUnit.test("parse_uri", function(assert) { +QUnit.test("parse_uri", function (assert) { assert.equal(typeof ext_bookmarks.parse_uri(""), "undefined"); assert.equal(typeof ext_bookmarks.parse_uri("asdf"), "undefined"); assert.equal(typeof ext_bookmarks.parse_uri("https://localhost:1234/Entity/sada?sadfasd#sdfgdsf"), "undefined"); - assert.propEqual(ext_bookmarks.parse_uri("safgsa/123&456&789?sadfasdf#_bm_1"), - {bookmarks: ["123", "456", "789"], collection_id: "1"}); + assert.propEqual(ext_bookmarks.parse_uri("safgsa/123&456&789?sadfasdf#_bm_1"), { + bookmarks: ["123", "456", "789"], + collection_id: "1" + }); }); -QUnit.test("get_bookmarks, clear_bookmark_storage", function(assert) { +QUnit.test("get_bookmarks, clear_bookmark_storage", function (assert) { assert.propEqual(ext_bookmarks.get_bookmarks(), []); ext_bookmarks.bookmark_storage[ext_bookmarks.get_key("sdfg")] = "3456" @@ -67,9 +69,9 @@ QUnit.test("get_export_table", async function (assert) { const NEWL = "%0A"; const context_root = connection.getBasePath() + "Entity/"; var table = await ext_bookmarks.get_export_table( - ["123@ver1", "456@ver2", "789@ver3", "101112", "@131415"]); + ["123@ver1", "456@ver2", "789@ver3", "101112", "@131415"]); assert.equal(table, - `data:text/csv;charset=utf-8,ID${TAB}Version${TAB}URI${TAB}Path${TAB}Name${TAB}RecordType${NEWL}123${TAB}ver1${TAB}${context_root}123@ver1${TAB}testpath_123@ver1${TAB}${TAB}${NEWL}456${TAB}ver2${TAB}${context_root}456@ver2${TAB}testpath_456@ver2${TAB}${TAB}${NEWL}789${TAB}ver3${TAB}${context_root}789@ver3${TAB}testpath_789@ver3${TAB}${TAB}${NEWL}101112${TAB}abcHead${TAB}${context_root}101112@abcHead${TAB}testpath_101112${TAB}${TAB}${NEWL}${TAB}131415${TAB}${context_root}@131415${TAB}testpath_@131415${TAB}${TAB}`); + `data:text/csv;charset=utf-8,ID${TAB}Version${TAB}URI${TAB}Path${TAB}Name${TAB}RecordType${NEWL}123${TAB}ver1${TAB}${context_root}123@ver1${TAB}testpath_123@ver1${TAB}${TAB}${NEWL}456${TAB}ver2${TAB}${context_root}456@ver2${TAB}testpath_456@ver2${TAB}${TAB}${NEWL}789${TAB}ver3${TAB}${context_root}789@ver3${TAB}testpath_789@ver3${TAB}${TAB}${NEWL}101112${TAB}abcHead${TAB}${context_root}101112@abcHead${TAB}testpath_101112${TAB}${TAB}${NEWL}${TAB}131415${TAB}${context_root}@131415${TAB}testpath_@131415${TAB}${TAB}`); }); @@ -115,7 +117,7 @@ QUnit.test("update_export_link", function (assert) { QUnit.test("update_collection_link", function (assert) { const collection_link = $( - `<div id="caosdb-f-bookmarks-collection-link"><a/></div>`); + `<div id="caosdb-f-bookmarks-collection-link"><a/></div>`); const a = collection_link.find("a")[0]; $("body").append(collection_link); @@ -148,7 +150,8 @@ QUnit.test("bookmark buttons", function (assert) { const non_button = $(`<div data-bla="sadf"/>)`); const outside_button = $(`<div data-bmval="id3"/>`); const inside_buttons = $("<div/>").append([inactive_button, active_button, - broken_button, non_button]); + broken_button, non_button + ]); // get_bookmark_buttons assert.equal(ext_bookmarks.get_bookmark_buttons("body").length, 0); @@ -174,8 +177,51 @@ QUnit.test("bookmark buttons", function (assert) { assert.ok(inactive_button.is(".active")); ext_bookmarks.clear_bookmark_storage(); - assert.notOk(inactive_button.is(".active"), "clear_bookmark_storage removes active class"); inside_buttons.remove(); outside_button.remove(); }); + +QUnit.test("select-query transformation", function (assert) { + assert.equal( + ext_bookmarks.get_select_id_query_string("FIND analysis"), + "SELECT ID FROM analysis"); + assert.equal( + ext_bookmarks.get_select_id_query_string( + "FIND RECORD analysis WHICH HAS A date > 2012"), + "SELECT ID FROM RECORD analysis WHICH HAS A date > 2012"); + assert.equal( + ext_bookmarks.get_select_id_query_string( + "SELECT name, date FROM analysis"), + "SELECT ID FROM analysis"); + assert.equal( + ext_bookmarks.get_select_id_query_string("COUNT analysis"), + "SELECT ID FROM analysis"); + assert.equal( + ext_bookmarks.get_select_id_query_string("fInD analysis"), + "SELECT ID FROM analysis"); +}); + +QUnit.test("select-query extraction", function (assert) { + // Use response field copied from demo + const response_field = $(`<div class="card caosdb-query-response mb-2"> + <div class="card-header caosdb-query-response-heading"> + <div class="row"> + <div class="col-sm-10 caosdb-overflow-box"> + <div class="caosdb-overflow-content"> + <span>Query: </span> + <span class = "caosdb-f-query-response-string">SELECT name, id FROM RECORD MusicalAnalysis</span> + </div> + </div> + <div class="col-sm-2 text-end"> + <span>Results: </span> + <span class="caosdb-query-response-results">3</span> + </div> + </div> + </div> +</div>`); + $("body").append(response_field); + + assert.equal(ext_bookmarks.get_query_from_response(), + "SELECT ID FROM RECORD MusicalAnalysis"); +}); \ No newline at end of file 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/form_panel.js.js b/test/core/js/modules/form_panel.js.js new file mode 100644 index 0000000000000000000000000000000000000000..bc8343d4a65233e025039e7476861fb998c2abbc --- /dev/null +++ b/test/core/js/modules/form_panel.js.js @@ -0,0 +1,63 @@ +/* + * ** header v3.0 + * This file is a part of the CaosDB Project. + * + * Copyright (C) 2021 IndiScale GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + * + * ** end header + */ + +'use strict'; + +QUnit.module("form_panel.js", { + before: function (assert) { + + }, + after: function (assert) { + } +}); + +QUnit.test("availability", function (assert) { + assert.ok(form_panel.init, "init available"); + assert.ok(form_panel.create_show_form_callback , "version available"); +}); + +QUnit.test("create_show_form_callback ", function (assert) { + const title = "Upload CSV File"; // title of the form and text in the toolbox + const panel_id = "csv_upload_form_panel"; + const server_side_script = "csv_script.py"; + const tool_box = "Tools"; // Name of the drop-down menu where the button is added in the navbar + const help_text = "something"; + const accepted_files_formats = [ ".csv", "text/tsv", ] // Mime types and file endings. + + const csv_form_config = { + script: server_side_script, + fields: [{ + type: "file", + name: "csv_file", + label: "CSV File", // label of the file selector in the form + required: true, + cached: false, + accept: accepted_files_formats.join(","), + help: help_text, + }, ], + }; + cb = form_panel.create_show_form_callback( panel_id, title, csv_form_config); + assert.equal(typeof cb, "function", "function created"); + cb() +}); + + diff --git a/test/core/js/modules/query.xsl.js b/test/core/js/modules/query.xsl.js index 371b51598918e2fd6bb5d94ca13a94337ccd322e..e644a674b6a8e58fd7c1395d1e337d5416c2bc5e 100644 --- a/test/core/js/modules/query.xsl.js +++ b/test/core/js/modules/query.xsl.js @@ -28,19 +28,25 @@ QUnit.module("query.xsl", { // load query.xsl var done = assert.async(); var qunit_obj = this; - $.ajax({ - cache: true, - dataType: 'xml', - url: "xsl/query.xsl", - }).done(function(data, textStatus, jdXHR) { - insertParam(data, "entitypath", "/entitypath/"); - qunit_obj.queryXSL = data; - }).always(function() { + _retrieveQueryXSL().then(function(xsl) { + qunit_obj.queryXSL = xsl; done(); }); } }); +async function _retrieveQueryXSL() { + var queryXsl = await transformation.retrieveXsltScript("query.xsl"); + var entityXsl = await transformation.retrieveXsltScript("entity.xsl"); + var commonXsl = await transformation.retrieveXsltScript("common.xsl"); + var xsl = transformation.mergeXsltScripts(entityXsl, [commonXsl, queryXsl]); + insertParam(xsl, "entitypath", "/entitypath/"); + insertParam(xsl, "filesystempath", "/filesystempath/"); + insertParam(xsl, "lowercase", "abcdefghijklmnopqrstuvwxyz"); + insertParam(xsl, "uppercase", "ABCDEFGHIJKLMNOPQRSTUVWXYZ"); + return xsl; +} + /* TESTS */ QUnit.test("availability", function(assert) { assert.ok(this.queryXSL); @@ -183,6 +189,35 @@ QUnit.test("template select-table-row ", function(assert){ assert.equal(next.tagName, "A", "tagName = A"); }); +QUnit.test("template select-table-cell (with version) ", function(assert){ + let cell = callTemplate(this.queryXSL, "select-table-cell", {"version-id": "vid-2345", "entity-id": "eid-1234", "field-name": "name"}, (x) => `<table><tbody><tr>${x}</tr></tbody></table>`,`<Response><Entity id="eid-1234" name="the-name"><Version id="vid-2345"/></Entity></Response>`); + var next = cell.firstElementChild; + assert.equal(next.tagName, "TABLE", "tagName = TABLE"); + next = next.firstElementChild; + assert.equal(next.tagName, "TBODY", "tagName = TBODY"); + next = next.firstElementChild; + assert.equal(next.tagName, "TR", "tagName = TR"); + next = next.firstElementChild; + assert.equal(next.tagName, "TD", "tagName = TD"); + next = next.textContent; + assert.equal(next, "the-name", "name = the-name"); +}); + + +QUnit.test("template select-table-cell (id only) ", function(assert){ + let cell = callTemplate(this.queryXSL, "select-table-cell", {"entity-id": "eid-1234", "field-name": "id"}, (x) => `<table><tbody><tr>${x}</tr></tbody></table>`,`<Response><Entity id="eid-1234"/></Response>`); + var next = cell.firstElementChild; + assert.equal(next.tagName, "TABLE", "tagName = TABLE"); + next = next.firstElementChild; + assert.equal(next.tagName, "TBODY", "tagName = TBODY"); + next = next.firstElementChild; + assert.equal(next.tagName, "TR", "tagName = TR"); + next = next.firstElementChild; + assert.equal(next.tagName, "TD", "tagName = TD"); + next = next.textContent; + assert.equal(next, "eid-1234", "id = eid-1234"); +}); + /* MISC FUNCTIONS */ function getQueryForm(queryXSL) { var html = callTemplate(queryXSL, "caosdb-query-panel", {}); diff --git a/test/core/js/modules/webcaosdb.js.js b/test/core/js/modules/webcaosdb.js.js index 52bf4ada52d2ce59b59d8615c89ca5796343622b..d2ef27952e41142a62eb70e144571bc9d30c52d2 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>'; @@ -731,12 +709,6 @@ QUnit.test("removeAllErrorNotifications", function (assert) { '<div class="caosdb-preview-waiting-notification">Please wait!</div></div>')[0]; let emptyElem = $('<div></div>')[0]; - assert.throws(() => { - preview.removeAllErrorNotifications(); - }, "no parameter throws"); - assert.throws(() => { - preview.removeAllErrorNotifications(null); - }, "null parameter throws"); assert.equal(okElem.childNodes.length, 3, "before: three children"); assert.equal(okElem, preview.removeAllErrorNotifications(okElem), "return first parameter"); @@ -753,13 +725,6 @@ QUnit.test("removeAllWaitingNotifications", function (assert) { '<div class="caosdb-preview-error-notification">Error!</div></div>')[0]; let emptyElem = $('<div></div>')[0]; - assert.throws(() => { - removeAllWaitingNotifications(); - }, "no parameter throws"); - assert.throws(() => { - removeAllWaitingNotifications(null); - }, "null parameter throws"); - assert.equal(okElem.childNodes.length, 3, "before: three children"); assert.equal(okElem, removeAllWaitingNotifications(okElem), "return first parameter"); assert.equal(okElem.childNodes.length, 1, "after: one child"); @@ -920,7 +885,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");