diff --git a/.gitlab/merge_request_templates/Default.md b/.gitlab/merge_request_templates/Default.md index 7859b7be21fb1c3eda91ee35173a8e3412a62066..35c6d01c5904289b77fc7f1de9419ef91a1510e9 100644 --- a/.gitlab/merge_request_templates/Default.md +++ b/.gitlab/merge_request_templates/Default.md @@ -26,6 +26,7 @@ guidelines](https://gitlab.com/caosdb/caosdb/-/blob/dev/REVIEW_GUIDELINES.md) - [ ] All automated tests pass - [ ] Reference related issues - [ ] Up-to-date CHANGELOG.md (or not necessary) +- [ ] Up-to-date JSON schema (or not necessary) - [ ] Appropriate user and developer documentation (or not necessary) - How do I use the software? Assume "stupid" users. - How do I develop or debug the software? Assume novice developers. diff --git a/CHANGELOG.md b/CHANGELOG.md index 812ecaee7671ab94f3c504be03666e09c6fa1e72..7d4f2b091f576a7d7468c6aa83bd41a628dcec83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,25 +4,46 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.10.1] - 2023-02-14 +## [Unreleased] - 2023-? ### Added +* `caosdb-v-property-linkified` css class to denote properties that have been + linkified already. +* `caosdb-f-property-datetime-value` css class for special handling of datetime properties. Also + there is now very basic customization (via variable `BUILD_MODULE_EXT_COSMETICS_CUSTOMDATETIME`) + for how datetime values can be displayed, which comes along with the new css classes + `caosdb-v-property-datetime-customized` and `caosdb-v-property-datetime-customized-newvalue`. +* `form_elements.make_form_modal` and + `form_elements.make_scripting_submission_button` functions to create a form + modal and an SSS submission button, respectively. + ### Changed (for changes in existing functionality) ### Deprecated +* `query_form` module. Enable/disable via build property + `BUILD_MODULE_LEGACY_QUERY_FORM={ENABLED,DISABLED}`. To be removed when + caosdb-webui-core-components are included into this webui permanently. + ### Removed ### Fixed -* [#194](https://gitlab.com/caosdb/caosdb-webui/-/issues/194) - Properties - remain hidden in previews of referenced entities +* [#199](https://gitlab.com/caosdb/caosdb-webui/-/issues/199) - Linkify creates + additional links after showing previews of referenced entities ### Security ### Documentation +## [0.10.1] - 2023-02-14 + +### Fixed + +* [#194](https://gitlab.com/caosdb/caosdb-webui/-/issues/194) - Properties + remain hidden in previews of referenced entities + ## [0.10.0] - 2022-12-19 (Florian Spreckelsen) diff --git a/build.properties.d/00_default.properties b/build.properties.d/00_default.properties index 9b8f8095befc01f3f9fba610ee61efbaa8c24fc5..d21943b6fc51abf370b2939a7ac8a6bad72e9a04 100644 --- a/build.properties.d/00_default.properties +++ b/build.properties.d/00_default.properties @@ -52,8 +52,10 @@ 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_COSMETICS_CUSTOMDATETIME=DISABLED BUILD_MODULE_EXT_QRCODE=ENABLED BUILD_MODULE_SHOW_ID_IN_LABEL=DISABLED +BUILD_MODULE_LEGACY_QUERY_FORM=ENABLED BUILD_MODULE_USER_MANAGEMENT=ENABLED BUILD_MODULE_USER_MANAGEMENT_CHANGE_OWN_PASSWORD_REALM=CaosDB @@ -168,6 +170,7 @@ MODULE_DEPENDENCIES=( loglevel.js plotly.js webcaosdb.js + query_form.js pako.js utif.js ext_version_history.js diff --git a/src/core/js/ext_cosmetics.js b/src/core/js/ext_cosmetics.js index 77556437394df6a6763661ce5c0d5001f68ce61a..c29f07e42294ff3d8c0b4b86da829865b8a650c5 100644 --- a/src/core/js/ext_cosmetics.js +++ b/src/core/js/ext_cosmetics.js @@ -1,8 +1,10 @@ /* * This file is a part of the CaosDB Project. * - * Copyright (C) 2021 IndiScale GmbH <info@indiscale.com> + * Copyright (C) 2021-2023 IndiScale GmbH <info@indiscale.com> * Copyright (C) 2021 Timm Fitschen <t.fitschen@indiscale.com> + * Copyright (C) 2023 Florian Spreckelsen <f.spreckelsen@indiscale.com> + * Copyright (C) 2023 Daniel Hornung <d.hornung@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 @@ -30,14 +32,38 @@ 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 + * longer than this will be cut off at the end and "[...]" will be * appended for the link text. */ var _link_cut_off_length = 40; + var _custom_datetime = function () { + $('.caosdb-f-property-datetime-value').each(function (index) { + if (!($(this).hasClass("caosdb-v-property-datetime-customized"))) { + var result = this.innerText.replace(/T/, " ").replace(/\+.*/, ""); + result = `<span class="caosdb-v-property-datetime-customized-newvalue">${result}</span>`; + + // add class to highlight that this has been customized already + $(this).addClass("caosdb-v-property-datetime-customized") + $(this).hide(); + $(this).after(result); + } + }); + } + + /** + * Remove all the custom datetime elements again, for example when entering the edit mode. + */ + var _custom_datetime_clear = function() { + $('.caosdb-v-property-datetime-customized-newvalue').each(function () { + $(this).remove(); + } + ) + } + var _linkify = function () { $('.caosdb-f-property-text-value').each(function (index) { - if (/https?:\/\//.test(this.innerText)) { + if (!($(this).hasClass("caosdb-v-property-linkified")) && (/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) { @@ -47,12 +73,31 @@ var cosmetics = new function () { return `<a title="Open ${href} in a new tab." target="_blank" class="caosdb-v-property-href-value" href="${href}">${link_text} <i class="bi bi-box-arrow-up-right"></i></a>`; }); + // add class to highlight that this has been linkified already + // (see https://gitlab.com/caosdb/caosdb-webui/-/issues/199). + $(this).addClass("caosdb-v-property-linkified") $(this).hide(); $(this).after(result); } }); } + /** + * Customize datetime formatting. + * + * A listener detects edit-mode changes and previews + */ + var custom_datetime = function () { + _custom_datetime(); + + // edit-mode-listener to delete replacement + document.body.addEventListener(edit_mode.start_edit.type, _custom_datetime_clear, true); + // edit-mode-listener to recreate + document.body.addEventListener(edit_mode.end_edit.type, _custom_datetime, true); + // preview listener + document.body.addEventListener(preview.previewReadyEvent.type, _custom_datetime, true); + } + /** * Convert any substring of a text-value beginning with 'http(s)://' into a * link. @@ -69,6 +114,10 @@ var cosmetics = new function () { } this.init = function () { + this.custom_datetime = custom_datetime; + if ("${BUILD_MODULE_EXT_COSMETICS_CUSTOMDATETIME}" == "ENABLED") { + custom_datetime(); + } this.linkify = linkify; if ("${BUILD_MODULE_EXT_COSMETICS_LINKIFY}" == "ENABLED") { linkify(); @@ -80,4 +129,4 @@ var cosmetics = new function () { $(document).ready(function () { caosdb_modules.register(cosmetics); -}); \ No newline at end of file +}); diff --git a/src/core/js/ext_prop_display.js b/src/core/js/ext_prop_display.js index bc4dc049b409fc1628704a6c778a2ec1c55ad638..3a8c616f503caadd4c0df04800bc8c15f0c068b2 100644 --- a/src/core/js/ext_prop_display.js +++ b/src/core/js/ext_prop_display.js @@ -21,6 +21,9 @@ 'use strict'; /** + * Hide or show properties for specific roles and users + * see src/doc/extension/display_of_properties.rst for documentation + * * @requires jQuery (library) * @requires log (singleton from loglevel library) * @requires load_config (function from webcaosdb.js) diff --git a/src/core/js/ext_trigger_crawler_form.js b/src/core/js/ext_trigger_crawler_form.js index 0796ef77da36e730b05d70dbbd2e8728c6e65c79..a6e1e3a18a3582cc9b3e511880b72d15f730b346 100644 --- a/src/core/js/ext_trigger_crawler_form.js +++ b/src/core/js/ext_trigger_crawler_form.js @@ -41,7 +41,7 @@ * variable `BUILD_MODULE_EXT_TRIGGER_CRAWLER_FORM_TOOLBOX`. The default is * `Tools`. */ -var ext_trigger_crawler_form = function () { +var ext_trigger_crawler_form = function ($, form_elements) { var init = function (toolbox) { const _toolbox = toolbox || "${BUILD_MODULE_EXT_TRIGGER_CRAWLER_FORM_TOOLBOX}"; @@ -52,7 +52,7 @@ var ext_trigger_crawler_form = function () { const crawler_form = make_scripting_caller_form( script, button_name); - const modal = make_form_modal(crawler_form); + const modal = form_elements.make_form_modal(crawler_form, "Trigger the crawler", "Crawl the selected path"); navbar.add_tool(button_name, _toolbox, { @@ -63,32 +63,6 @@ var ext_trigger_crawler_form = function () { }); } - /** - * Wrap the form into a Bootstrap modal. - */ - var make_form_modal = function (form) { - const title = "Trigger the Crawler"; - const modal = $(` - <div class="modal fade" tabindex="-1" role="dialog"> - <div class="modal-dialog" role="document"> - <div class="modal-content"> - <div class="modal-header"> - <button type="button" - class="btn-close" - data-bs-dismiss="modal" - aria-label="Close"> - <span aria-hidden="true">×</span> - </button> - <h4 class="modal-title">${title}</h4> - </div> - <div class="modal-body"> - </div> - </div> - </div>`); - modal.find(".modal-body").append(form); - return modal[0]; - } - /** * Create the trigger crawler form. */ @@ -104,15 +78,7 @@ var ext_trigger_crawler_form = function () { }); $(warning_checkbox).find("input").attr("value", "TRUE"); - const scripting_caller = $(` - <form method="POST" action="/scripting"> - <input type="hidden" name="call" value="${script}"/> - <input type="hidden" name="-p0" value=""/> - <div class="form-control"> - <input type="submit" - class="form-control btn btn-primary" value="${button_name}"/> - </div> - </form>`); + const scripting_caller = form_elements.make_scripting_submission_button(script, button_name); scripting_caller.prepend(warning_checkbox).prepend(path_field); @@ -123,7 +89,7 @@ var ext_trigger_crawler_form = function () { init: init, }; -}(); +}($, form_elements); $(document).ready(function () { if ("${BUILD_MODULE_EXT_TRIGGER_CRAWLER_FORM}" === "ENABLED") { diff --git a/src/core/js/form_elements.js b/src/core/js/form_elements.js index 6815acd791213c6b239a693c3c64667965c369ed..20ff4ead80d897bd8b90497c7c0f03351c0d92ff 100644 --- a/src/core/js/form_elements.js +++ b/src/core/js/form_elements.js @@ -1437,6 +1437,10 @@ var form_elements = new function () { /** * Return a select field. * + * IMPORTANT: The select picker has to be initialized by the client by + * calling ``form_elements.init_select_picker(ret, config.value)`` (see + * below and https://gitlab.com/caosdb/caosdb-webui/-/issues/208). + * * @param {SelectFieldConfig} config * @returns {HTMLElement} a select field. */ @@ -1454,7 +1458,7 @@ var form_elements = new function () { // case when this method is called and is controlled by the client. So // there is currently no other work-around than to call // init_select_picker after the form creation explicitely :( - //form_elements.init_select_picker(select[0], config.value); + // form_elements.init_select_picker(ret, config.value); return ret; } @@ -1563,6 +1567,58 @@ var form_elements = new function () { } } + /** + * Return a modal HTML element containing the given form. + * + * @param {HTMLElement} form - the form to be shown in the modal + * @param {string} title - the title of the form modal + * @param {string} explanationText - An optional paragraph shown between + * modal title and form. + */ + this.make_form_modal = function (form, title, explanationText) { + const modal = $(` + <div class="modal fade" tabindex="-1" role="dialog"> + <div class="modal-dialog" role="document"> + <div class="modal-content"> + <div class="modal-header"> + <h4 class="modal-title">${title}</h4> + <button type="button" + class="btn-close" + data-bs-dismiss="modal" + aria-label="Close"> + </button> + </div> + <div class="modal-body"> + <p>${explanationText}</p> + </div> + </div> + </div>`); + + modal.find(".modal-body").append(form); + return modal[0]; + } + + /** + * Return a submission button that triggers a given server-side-script. + * + * @param {string} script - Name of the server-side script to be triggered + * @param {string} buttonName - Display name of the submission button + */ + this.make_scripting_submission_button = function (script, buttonName) { + let button_name = buttonName || "Submit"; + const scripting_caller = $(` + <form method="POST" action="/scripting"> + <input type="hidden" name="call" value="${script}"/> + <input type="hidden" name="-p0" value=""/> + <div class="form-group"> + <input type="submit" + class="form-control btn btn-primary" value="${button_name}"/> + </div> + </form>`); + + return scripting_caller + } + /** * Return an input and a label, wrapped in a div with class diff --git a/src/core/js/query_form.js b/src/core/js/query_form.js new file mode 100644 index 0000000000000000000000000000000000000000..5b3ae1cd78761d9794de4da36fec5a7e58c1ad46 --- /dev/null +++ b/src/core/js/query_form.js @@ -0,0 +1,270 @@ +/** + * Extend the functionality of the pure html query panel. + * + * Deprecated. This is to be replaced by the query form of caosdb-webui-core-components. + * + * @deprecated + * @module queryForm + * @global + */ +var queryForm = (function () { + const init = function (form) { + queryForm.restoreLastQuery(form, () => window.sessionStorage.lastQuery); + queryForm.bindOnClick(form, (set) => { + window.sessionStorage.lastQuery = set; + }); + const BUILD_FREE_SEARCH_ROLE_NAME_FACET_OPTIONS = ""; + queryForm.initFreeSearch( + form, + `${BUILD_FREE_SEARCH_ROLE_NAME_FACET_OPTIONS}` + ); + }; + + const logger = log.getLogger("queryForm"); + var role_name_facet_select = undefined; + + const _isCql = function (query) { + query = query.toUpperCase().trim(); + return ( + query.startsWith("FIND") || + query.startsWith("COUNT") || + query.startsWith("SELECT") + ); + }; + + /** + * Initialize the free search to generate search queries which search only + * within one of several options of roles or entity names. + * + * E.g. when `options` is "Person, Experiment, Sample" the user can select + * one of these options before submitting the query. When the user types in + * something that doesn't looks like a CQL-query, a query is generated + * instead which goes like: FIND Persion WHICH HAS A PROPERTY LIKE + * "*something*". + * + * Note: This feature is disabled by default. Enable it by specifying the + * build variable BUILD_FREE_SEARCH_ROLE_NAME_FACET_OPTIONS as a + * comma-separated list of **properly quoted** expressions, e.g. + * "FILE 'numpy array', 'Plant Experiemnt', 'quotes_not_necessary'". + * Otherwise, the server will throw a syntax error. + * + * @function initFreeSearch + * @param {HTMLElement} form - the form which will be initialized for the + * free search. + * @param {string} options - comma-separated list of options. + * @return {boolean} - true if the initialization was successful, false + * otherwise. + */ + const initFreeSearch = function (form, options) { + const textArea = $(form).find(".caosdb-f-query-textarea"); + logger.trace("initFreeSearch", form, textArea, options); + if (textArea.length > 0 && options && options != "") { + const lastQuery = + window.localStorage["freeTextQuery:" + textArea[0].value]; + if (lastQuery) { + textArea[0].value = lastQuery; + } + + const selected = window.localStorage["role_name_facet_option"]; + const select = $(`<select class="btn btn-secondary"/>`); + for (let option of options.split(",")) { + select.append( + `<option ${ + selected === option.trim() ? "selected" : "" + }>${option}</option>` + ); + } + $(form).find(".input-group").prepend(select); + role_name_facet_select = select[0]; + + const switchFreeSearch = (text_area) => { + if (_isCql(text_area.value)) { + select.hide(); + $(text_area).css({ + "border-top-left-radius": "0.375rem", + "border-bottom-left-radius": "0.375rem", + }); + } else { + select.show(); + $(text_area).css({}); + } + }; + + switchFreeSearch(textArea[0]); + + textArea.on("keydown", (e) => { + switchFreeSearch(e.target); + }); + return true; + } + role_name_facet_select = undefined; + return false; + }; + + /** + * @function restoreLastQuery + */ + const restoreLastQuery = function (form, getter) { + if (form == null) { + throw new Error("form was null"); + } + if (getter()) { + form.query.value = getter(); + } + }; + + /** + * @function redirect + * @param {string} query - the query string. + * @param {string} paging - the paging string, e.g. 0L10. + */ + const redirect = function (query, paging) { + var pagingparam = ""; + if (paging && paging.length > 0) { + pagingparam = "P=" + paging + "&"; + } + location.href = + connection.getBasePath() + "Entity/?" + pagingparam + "query=" + query; + }; + + /** + * Read out the selector for the role/name facet of the query. Return + * "RECORD" if the selector is disabled. + * @function getRoleNameFacet + */ + const getRoleNameFacet = function () { + if (role_name_facet_select) { + const result = role_name_facet_select.value; + window.localStorage["role_name_facet_option"] = result; + return result; + } + return "RECORD"; + }; + + const _splitSearchTermsPattern = + /"(?<dq>[^"]*)" |'(?<sq>[^']*)' |(?<nq>[^ ]+)/g; + + /** + * Split a query string into single terms. + * + * Terms are separated by white spaces. Terms which contain white spaces + * which are to be preserved must be enclosed in " or ' quotes. The + * enclosing quotation marks are being stripped. Currently no support for + * escape sequences for quotation marks. + * + * @function splitSearchTerms + * @param {string} query - complete query string. + * @return {string[]} array of the search terms. + */ + const splitSearchTerms = function (query) { + // add empty space at the end, so every matching group ends with it -> easier regex. Also, undefined is filtered out + return Array.from( + (query + " ").matchAll(_splitSearchTermsPattern), + (m) => m[1] || m[2] || m[3] + ).filter((word) => word); + }; + + /** + * Is the query a SELECT field,... FROM entity query? + * + * @function isSelectQuery + * @param {HTMLElement} query, the query to be tested. + * @return {Boolean} + */ + const isSelectQuery = function (query) { + return query.toUpperCase().startsWith("SELECT"); + }; + + /** + * @function bindOnClick + */ + const bindOnClick = function (form, setter) { + if (setter == null || typeof setter !== "function" || setter.length !== 1) { + throw new Error("setter must be a function with one param"); + } + + /* + Here a submit handler is created that is attached to both the form submit handler + and the click handler of the button. + See https://developer.mozilla.org/en-US/docs/Web/Events/submit why this is necessary. + */ + var submithandler = function () { + // store current query + var queryField = form.query; + var value = queryField.value; + if (typeof value == "undefined" || value.length == 0) { + return; + } + if (!_isCql(value)) { + // split words in query field at space and create query fragments + var words = splitSearchTerms(queryField.value).map( + (word) => `A PROPERTY LIKE '*${word.replaceAll("'", `\\'`)}*'` + ); + if (!words.length) { + return false; + } + const e = getRoleNameFacet(); + const query_string = `FIND ${e} WHICH HAS ` + words.join(" AND "); + queryField.value = query_string; + + // store original value of the text field + window.localStorage["freeTextQuery:" + query_string] = value; + } + setter(queryField.value); + + var paging = ""; + if (form.P && !isSelectQuery(queryField.value)) { + paging = form.P.value; + } + + queryForm.redirect(queryField.value.trim(), paging); + + var btn = $(form).find(".caosdb-search-btn"); + btn.html( + `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>` + ); + btn.prop("disabled", true); + + var textField = $(form).find(".caosdb-f-query-textarea"); + textField.blur(); + textField.prop("disabled", true); + }; + + // handler for the form + form.onsubmit = function (e) { + e.preventDefault(); + submithandler(); + return false; + }; + + // same handler for the button + form.getElementsByClassName("caosdb-search-btn")[0].onclick = function () { + submithandler(); + }; + }; + + /** + * @function getRoleNameFacetSelect + */ + + return { + init: init, + initFreeSearch: initFreeSearch, + isSelectQuery: isSelectQuery, + restoreLastQuery: restoreLastQuery, + redirect: redirect, + bindOnClick: bindOnClick, + splitSearchTerms: splitSearchTerms, + getRoleNameFacet: getRoleNameFacet, + getRoleNameFacetSelect: () => role_name_facet_select, + }; +})(); + +$(document).ready(function () { + if (`${BUILD_MODULE_LEGACY_QUERY_FORM}` == "ENABLED") { + var form = document.getElementById("caosdb-query-form"); + if (form != null) { + queryForm.init(form); + } + } +}); diff --git a/src/core/js/webcaosdb.js b/src/core/js/webcaosdb.js index bfe0eb35c8e72e6493ad273ae0ed5a727019c628..18387779cfe8a9ee2ac34a1b3e69fa3f1127689f 100644 --- a/src/core/js/webcaosdb.js +++ b/src/core/js/webcaosdb.js @@ -1200,242 +1200,6 @@ var paging = new function () { } }; -/** - * Extend the functionality of the pure html query panel. - * - * @module queryForm - * @global - */ -var queryForm = function () { - - const init = function (form) { - queryForm.restoreLastQuery(form, () => window.sessionStorage.lastQuery); - queryForm.bindOnClick(form, (set) => { - window.sessionStorage.lastQuery = set; - }); - const BUILD_FREE_SEARCH_ROLE_NAME_FACET_OPTIONS = ""; - queryForm.initFreeSearch(form, `${BUILD_FREE_SEARCH_ROLE_NAME_FACET_OPTIONS}`); - }; - - const logger = log.getLogger("queryForm"); - var role_name_facet_select = undefined; - - const _isCql = function (query) { - query = query.toUpperCase().trim(); - return (query.startsWith("FIND") || query.startsWith("COUNT") || query.startsWith("SELECT")); - } - - /** - * Initialize the free search to generate search queries which search only - * within one of several options of roles or entity names. - * - * E.g. when `options` is "Person, Experiment, Sample" the user can select - * one of these options before submitting the query. When the user types in - * something that doesn't looks like a CQL-query, a query is generated - * instead which goes like: FIND Persion WHICH HAS A PROPERTY LIKE - * "*something*". - * - * Note: This feature is disabled by default. Enable it by specifying the - * build variable BUILD_FREE_SEARCH_ROLE_NAME_FACET_OPTIONS as a - * comma-separated list of **properly quoted** expressions, e.g. - * "FILE 'numpy array', 'Plant Experiemnt', 'quotes_not_necessary'". - * Otherwise, the server will throw a syntax error. - * - * @function initFreeSearch - * @param {HTMLElement} form - the form which will be initialized for the - * free search. - * @param {string} options - comma-separated list of options. - * @return {boolean} - true if the initialization was successful, false - * otherwise. - */ - const initFreeSearch = function (form, options) { - const textArea = $(form).find(".caosdb-f-query-textarea"); - logger.trace("initFreeSearch", form, textArea, options); - if (textArea.length > 0 && options && options != "") { - const lastQuery = window.localStorage["freeTextQuery:" + textArea[0].value]; - if (lastQuery) { - textArea[0].value = lastQuery; - } - - const selected = window.localStorage["role_name_facet_option"]; - const select = $(`<select class="btn btn-secondary"/>`); - for (let option of options.split(",")) { - select.append(`<option ${selected === option.trim() ? "selected" : ""}>${option}</option>`); - } - $(form).find(".input-group").prepend(select); - role_name_facet_select = select[0]; - - const switchFreeSearch = (text_area) => { - if(_isCql(text_area.value)) { - select.hide(); - $(text_area).css({"border-top-left-radius": "0.375rem", "border-bottom-left-radius": "0.375rem"}); - } else { - select.show(); - $(text_area).css({}); - } - } - - switchFreeSearch(textArea[0]); - - textArea.on("keydown", (e) => { - switchFreeSearch(e.target); - }); - return true; - } - role_name_facet_select = undefined; - return false; - } - - /** - * @function restoreLastQuery - */ - const restoreLastQuery = function (form, getter) { - if (form == null) { - throw new Error("form was null"); - } - if (getter()) { - form.query.value = getter(); - } - }; - - /** - * @function redirect - * @param {string} query - the query string. - * @param {string} paging - the paging string, e.g. 0L10. - */ - const redirect = function (query, paging) { - var pagingparam = "" - if (paging && paging.length > 0) { - pagingparam = "P=" + paging + "&"; - } - location.href = connection.getBasePath() + "Entity/?" + pagingparam + "query=" + query; - } - - /** - * Read out the selector for the role/name facet of the query. Return - * "RECORD" if the selector is disabled. - * @function getRoleNameFacet - */ - const getRoleNameFacet = function () { - if (role_name_facet_select) { - const result = role_name_facet_select.value; - window.localStorage["role_name_facet_option"] = result; - return result; - } - return "RECORD"; - } - - const _splitSearchTermsPattern = /"(?<dq>[^"]*)" |'(?<sq>[^']*)' |(?<nq>[^ ]+)/g; - - /** - * Split a query string into single terms. - * - * Terms are separated by white spaces. Terms which contain white spaces - * which are to be preserved must be enclosed in " or ' quotes. The - * enclosing quotation marks are being stripped. Currently no support for - * escape sequences for quotation marks. - * - * @function splitSearchTerms - * @param {string} query - complete query string. - * @return {string[]} array of the search terms. - */ - const splitSearchTerms = function (query) { - // add empty space at the end, so every matching group ends with it -> easier regex. Also, undefined is filtered out - return Array.from((query + " ").matchAll(_splitSearchTermsPattern), (m) => m[1] || m[2] || m[3]).filter((word) => word); - } - - /** - * Is the query a SELECT field,... FROM entity query? - * - * @function isSelectQuery - * @param {HTMLElement} query, the query to be tested. - * @return {Boolean} - */ - const isSelectQuery = function (query) { - return query.toUpperCase().startsWith("SELECT"); - } - - /** - * @function bindOnClick - */ - const bindOnClick = function (form, setter) { - if (setter == null || typeof (setter) !== 'function' || setter.length !== 1) { - throw new Error("setter must be a function with one param"); - } - - /* - Here a submit handler is created that is attached to both the form submit handler - and the click handler of the button. - See https://developer.mozilla.org/en-US/docs/Web/Events/submit why this is necessary. - */ - var submithandler = function () { - // store current query - var queryField = form.query; - var value = queryField.value; - if (typeof value == "undefined" || value.length == 0) { - return; - } - if (!_isCql(value)) { - // split words in query field at space and create query fragments - var words = splitSearchTerms(queryField.value).map(word => `A PROPERTY LIKE '*${word.replaceAll("'", `\\'`)}*'`); - if (!words.length) { - return false; - } - const e = getRoleNameFacet(); - const query_string = `FIND ${e} WHICH HAS ` + words.join(" AND "); - queryField.value = query_string; - - // store original value of the text field - window.localStorage["freeTextQuery:" + query_string] = value; - } - setter(queryField.value); - - var paging = ""; - if (form.P && !isSelectQuery(queryField.value)) { - paging = form.P.value - } - - queryForm.redirect(queryField.value.trim(), paging); - - var btn = $(form).find(".caosdb-search-btn"); - btn.html(`<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>`); - btn.prop("disabled", true); - - var textField = $(form).find(".caosdb-f-query-textarea"); - textField.blur(); - textField.prop("disabled", true); - }; - - // handler for the form - form.onsubmit = function (e) { - e.preventDefault(); - submithandler(); - return false; - }; - - // same handler for the button - form.getElementsByClassName("caosdb-search-btn")[0].onclick = function () { - submithandler(); - }; - }; - - /** - * @function getRoleNameFacetSelect - */ - - return { - init: init, - initFreeSearch: initFreeSearch, - isSelectQuery: isSelectQuery, - restoreLastQuery: restoreLastQuery, - redirect: redirect, - bindOnClick: bindOnClick, - splitSearchTerms: splitSearchTerms, - getRoleNameFacet: getRoleNameFacet, - getRoleNameFacetSelect: () => role_name_facet_select, - } -}(); - /* * Small module containing only a converter from markdown to html. @@ -1912,12 +1676,6 @@ function initOnDocumentReady() { paging.init(); hintMessages.init(); - // init query form - var form = document.getElementById("caosdb-query-form"); - if (form != null) { - queryForm.init(form); - } - // show image 100% width $(".entity-image-preview").click(function () { $(this).css('max-width', '100%'); diff --git a/src/core/xsl/entity.xsl b/src/core/xsl/entity.xsl index 264136f09828dce4e2ba7d324ff09fc14db5842c..3881e722608c6013b433a815f2db72a1896cd76e 100644 --- a/src/core/xsl/entity.xsl +++ b/src/core/xsl/entity.xsl @@ -297,6 +297,7 @@ <xsl:param name="value"/> <xsl:param name="reference"/> <xsl:param name="boolean"/> + <xsl:param name="datetime"/> <xsl:choose> <xsl:when test="normalize-space($value)!=''"> <xsl:choose> @@ -322,6 +323,14 @@ <xsl:value-of select="normalize-space($value)"/> </xsl:element> </xsl:when> + <xsl:when test="$datetime='true'"> + <xsl:element name="span"> + <xsl:attribute name="class"> + <xsl:value-of select="'caosdb-f-property-single-raw-value caosdb-property-datetime-value caosdb-f-property-datetime-value caosdb-v-property-datetime-value'"/> + </xsl:attribute> + <xsl:value-of select="$value"/> + </xsl:element> + </xsl:when> <xsl:otherwise> <xsl:element name="span"> <xsl:attribute name="class"> @@ -362,6 +371,9 @@ <xsl:with-param name="boolean"> <xsl:value-of select="'false'"/> </xsl:with-param> + <xsl:with-param name="datetime"> + <xsl:value-of select="'false'"/> + </xsl:with-param> </xsl:call-template> </xsl:for-each> <xsl:for-each select="Record|RecordType|File|Property"> @@ -375,6 +387,9 @@ <xsl:with-param name="boolean"> <xsl:value-of select="'false'"/> </xsl:with-param> + <xsl:with-param name="datetime"> + <xsl:value-of select="'false'"/> + </xsl:with-param> </xsl:call-template> </xsl:for-each> </xsl:element> @@ -398,6 +413,9 @@ <xsl:with-param name="boolean"> <xsl:value-of select="../@datatype='LIST<BOOLEAN>'"/> </xsl:with-param> + <xsl:with-param name="datetime"> + <xsl:value-of select="../@datatype='LIST<DATETIME>'"/> + </xsl:with-param> </xsl:call-template> </xsl:element> </xsl:for-each> @@ -449,6 +467,9 @@ <xsl:with-param name="boolean"> <xsl:value-of select="@datatype='BOOLEAN'"/> </xsl:with-param> + <xsl:with-param name="datetime"> + <xsl:value-of select="@datatype='DATETIME'"/> + </xsl:with-param> </xsl:call-template> </xsl:otherwise> </xsl:choose> diff --git a/src/doc/conf.py b/src/doc/conf.py index 0fa24ff8f99ce661c4e49b8059322ca09436c395..cde1d8d627cd3364159ffdb899a701dd72eedd82 100644 --- a/src/doc/conf.py +++ b/src/doc/conf.py @@ -26,9 +26,9 @@ copyright = '2022, IndiScale GmbH' author = 'Daniel Hornung' # The short X.Y version -version = '0.10.1' +version = '0.10.2' # The full version, including alpha/beta/rc tags -release = '0.10.1-SNAPSHOT' +release = '0.10.2-SNAPSHOT' # -- General configuration --------------------------------------------------- diff --git a/src/doc/extension/forms.rst b/src/doc/extension/forms.rst index e1891b8d7e571c4a273cec98fcc39e50398936f0..11a7167d1277c9fbb7ed6dda6e611ce4aca48302 100644 --- a/src/doc/extension/forms.rst +++ b/src/doc/extension/forms.rst @@ -22,7 +22,7 @@ The following code snippet adds a form to the body of the HTML document. const config = { name: "my_form", fields: [ - { type: "reference_drop_down", name: "experiment_id", label: "Experiment", query: "FIND Record Experiment", required: true }, + { type: "reference_drop_down", name: "experiment_id", label: "Experiment", query: "FIND Experiment", required: true }, { type: "integer", name: "number", label: "A Number", required: true }, { type: "date", name: "date", label: "A Date", required: false }, { type: "text", name: "comment", label: "A Comment", required: false }, @@ -85,7 +85,7 @@ If you intend to call a server-side script, the config has to be changed a litte const config = { script: "process.py", fields: [ - { type: "reference_drop_down", name: "experiment_id", label: "Experiment", query: "FIND Record Experiment", required: true }, + { type: "reference_drop_down", name: "experiment_id", label: "Experiment", query: "FIND Experiment", required: true }, { type: "integer", name: "number", label: "A Number", required: true }, { type: "date", name: "date", label: "A Date", required: false }, { type: "text", name: "comment", label: "A Comment", required: false }, diff --git a/src/doc/extension/module.md b/src/doc/extension/module.md index 207d3fff9d013cda657ef8c01f3cca5988622f4a..36731ee72d7a1e4be2f4624961486152579a0ff5 100644 --- a/src/doc/extension/module.md +++ b/src/doc/extension/module.md @@ -1,16 +1,15 @@ # How to add a module to CaosDB WebUI -The CaosDB WebUI is organized in modules which can easily be added and on a module basis enabled or disabled. +The CaosDB WebUI is organized in modules which can easily be added and enabled or disabled per module. -There are a few steps necessary to create a new module. +Only a few steps are necessary to create a new module. ## Create the module 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`. +Create a new file for each new module. We have the convention that extensions which are optional +(and also custom extensions for special purposes) are saved in files starting with `ext_`, +e.g. `ext_flight_preview.js`. -This file should define one function that wraps every thing and which is +This file should define one function that wraps everything, this function is then enabled at the bottom of the file: ```js diff --git a/src/doc/extension/query_templates.rst b/src/doc/extension/query_templates.rst index 4e511ac9f91b26ffcda2674362b00070948c1bd2..8c331386468c4392e6f2afa7d02264fb5cc1eadb 100644 --- a/src/doc/extension/query_templates.rst +++ b/src/doc/extension/query_templates.rst @@ -1,3 +1,7 @@ +=============== +Query shortcuts +=============== + Introduction ============ @@ -7,11 +11,10 @@ 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 + :alt: Shortcuts in the query panel. There is a + toolbox for editing shortcuts in the top right. - The Shortcuts in the Query Panel; Note the Toolbox for in the top - right + Shortcuts in the query panel. Note the toolbox for editing shortcuts in the top right There are two ways to integrate query templates into the WebUI: @@ -47,7 +50,7 @@ It now opens a form with two input fields, ``Description`` and The view to create a new shortcut -See `2.4 <#basic-shortcut>`__ and `2.5 <#advanced-shortcut>`__ for +See `Basic Shortcut`_ and `Advanced Shortcut`_ for further explanation of the components of a Query Shortcut. Edit the fields and click ``Submit`` for the creation of the new @@ -177,6 +180,10 @@ 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*. + +Global Shortcuts +================ + Example for global_query_shortcuts.json --------------------------------------- @@ -191,7 +198,7 @@ The following example for the file global_query_shortcuts.json would create two }, { "description": "Show a table of Experiments for year: {year}", - "query": "SELECT date, project, identifier FROM Record Experiment with date in {year}" + "query": "SELECT date, project, identifier FROM Experiment with date in {year}" } ] diff --git a/src/doc/tutorials/query.rst b/src/doc/tutorials/query.rst index 29d998cc1dbce5c5e17ae4cf9f60176b9f88861a..bd57093869bdb8ecb52e7fe93d6ac5a3f6d03328 100644 --- a/src/doc/tutorials/query.rst +++ b/src/doc/tutorials/query.rst @@ -14,31 +14,31 @@ the webinterface under the respective menu entry. Let's start with a simple one:: - FIND RECORD MusicalInstrument + FIND MusicalInstrument Most queries simply start with the ``FIND`` keyword and describe what we are -looking for behind that. The ``RECORD`` keyword denotes that we are only looking -for Records (and not Files, Properties or RecordTypes). Finally, we provided -a RecordType name: MusicalInstrument. This means that we will get all Records -that have this RecordType as parent. Try it out! +looking for behind that. Then, we provided a RecordType name: +MusicalInstrument. This means that we will get all Records that have this +RecordType as parent. Try it out! Let's look at:: - FIND Guitar + FIND ENTITY Guitar -When we leave out the ``RECORD`` keyword, we will get every entity that is a -Guitar. When you submit this query you should find also a RecordType Guitar -in the results. Using ``FIND RecordType Guitar`` would restrict the result to -only that RecordType. +When we add the ``ENTITY`` keyword we will get every entity that is a +Guitar -- also the RecordType, and even a Property with that name if there +exists one. Using ``FIND RecordType Guitar`` would restrict the result to +only that RecordType. And ``FIND RECORD MusicalInstrument`` is just equivalent +to ``FIND MusicalInstrument``. -Note, that you cannot only provide RecordType names after the ``FIND``, but names -in general: ``FIND RECORD Nice Guitar``. This will give you a Record with the +Note, that you can provide not only RecordType names after the ``FIND``, but names +in general: ``FIND "Nice Guitar"``. This will give you a Record with the name "Nice Guitar" (if one exists... and there should be one in the demo instance). While it does not matter whether you use capital letters or not, the names have to be exact. There are two features that make it easy to use names for querying in spite of this: -- You can use "*" to match any string. E.g. ``FIND RECORD Nice*`` +- You can use "*" to match any string. E.g. ``FIND Nice*`` - After typing three letters, names that start with those three are suggested by the auto completion. @@ -64,7 +64,9 @@ result set. In general this looks like:: FIND <Name> <Property Filter> Typically, the filter has the form ``<Property> <Operator> <Value>``, -for example ``length >= 0.7mm``. +for example ``length >= 0.7mm``. Instead of the ``<Name>`` you can also use one +of the entity roles, namely ``RECORD``, ``RECORDTYPE``, ``FILE``, ``PROPERY``, +or ``ENTITY``. There are many filters available. You can check the specification for a comprehensive description of those. Here, we will only look at the most common examples. @@ -72,7 +74,7 @@ those. Here, we will only look at the most common examples. If you only want to assure that Records have a certain Property, without imposing constrains on the value, you can use:: - FIND RECORD MusicalInstrument WITH Manufacturer + FIND MusicalInstrument WITH Manufacturer Similarly, to what we saw above when using incomplete names, you can use a "*" @@ -138,6 +140,6 @@ information in a table. A comma separated list of Property names can be provided Or:: - SELECT quality_factor, report, date FROM Analysis WHICH REFERENCES A Guitar WITH electric=TRUE + SELECT quality_factor, report, date FROM Analysis WHICH REFERENCES A Guitar WITH electric=TRUE diff --git a/test/core/js/modules/ext_cosmetics.js.js b/test/core/js/modules/ext_cosmetics.js.js index 969c8297b8b5cf85d0a668d7c30e8b0f45e34d4d..f51226ee01a1607c9ba1580c3760500fdec760b1 100644 --- a/test/core/js/modules/ext_cosmetics.js.js +++ b/test/core/js/modules/ext_cosmetics.js.js @@ -1,8 +1,9 @@ /* * This file is a part of the CaosDB Project. * - * Copyright (C) 2021 IndiScale GmbH <info@indiscale.com> + * Copyright (C) 2021-2023 IndiScale GmbH <info@indiscale.com> * Copyright (C) 2021 Timm Fitschen <t.fitschen@indiscale.com> + * Copyright (C) 2023 Daniel Hornung <d.hornung@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 @@ -36,6 +37,30 @@ QUnit.module("ext_cosmetics.js", { } }); +QUnit.test("custom datetime", function (assert) { + assert.ok(cosmetics.custom_datetime, "custom_datetime available"); + var test_cases = [ + ["1234-56-78", "1234-56-78"], + ["9876-54-32T12:34:56", "9876-54-32 12:34:56"], + ["2023-03-78T99:99:99+0800", "2023-03-78 99:99:99"], + ]; + + for (let test_case of test_cases) { + const container = $('<div></div>'); + $(document.body).append(container); + const text_value = $(`<span class="caosdb-f-property-datetime-value">${test_case[0]}</span>`); + container.append(text_value); + assert.equal($(container).find(" ").length, 0, "Test original datetime."); + cosmetics.custom_datetime(); + const newValueElement = + container[0].querySelector("span.caosdb-v-property-datetime-customized-newvalue"); + assert.ok(newValueElement, "Datetime customization: Test if result exists."); + assert.equal(newValueElement.innerHTML, test_case[1], + "Datetime customization: compared result."); + container.remove(); + } +}); + QUnit.test("linkify - https", function (assert) { assert.ok(cosmetics.linkify, "linkify available"); var test_cases = [