diff --git a/.gitignore b/.gitignore index f69db87ad5a5226535559b6965e771d975ded103..a89ffdab632a2cd881f66cb20e1f154c17cc9387 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ xerr.log conf/ext test/ext src/ext +*~ diff --git a/CHANGELOG.md b/CHANGELOG.md index aaace618e1ba063ee7ddd13148c96da71607f9f3..41f569a3647f8d428c5b95674a4ee9050a8e6317 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,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 +27,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/build.properties.d/00_default.properties b/build.properties.d/00_default.properties index 58d20c521100bbd43e094c2da402abc38ea555bc..386eb8bd22bd3f32b94d9b9ea4714b80e355ea8e 100644 --- a/build.properties.d/00_default.properties +++ b/build.properties.d/00_default.properties @@ -42,7 +42,6 @@ # Modules enabled/disabled by default ############################################################################## BUILD_MODULE_EXT_PREVIEW=ENABLED -BUILD_MODULE_EXT_RESOLVE_REFERENCES=ENABLED BUILD_MODULE_EXT_SSS_MARKDOWN=DISABLED BUILD_MODULE_EXT_TRIGGER_CRAWLER_FORM=DISABLED BUILD_MODULE_EXT_AUTOCOMPLETE=ENABLED @@ -51,10 +50,14 @@ BUILD_MODULE_EXT_BOTTOM_LINE_TABLE_PREVIEW=DISABLED BUILD_MODULE_EXT_BOTTOM_LINE_TIFF_PREVIEW=DISABLED BUILD_MODULE_EXT_BOOKMARKS=ENABLED BUILD_MODULE_EXT_ANNOTATION=ENABLED +BUILD_MODULE_EXT_COSMETICS_LINKIFY=DISABLED BUILD_MODULE_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 +151,5 @@ MODULE_DEPENDENCIES=( ext_sss_markdown.js ext_trigger_crawler_form.js ext_bookmarks.js + ext_cosmetics.js ) 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_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_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/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/doc/extension/references.rst b/src/doc/extension/references.rst new file mode 100644 index 0000000000000000000000000000000000000000..63c551612e5e9d807846595b6c5e458bc5096615 --- /dev/null +++ b/src/doc/extension/references.rst @@ -0,0 +1,38 @@ +Customizing the display of referenced entities +============================================= + +CaosDB WebUI supports the customized display of referenced entities +using the :doc:`ext_references <../api/module-resolve_references>` +module. The ``BUILD_MODULE_EXT_RESOLVE_REFERENCES`` build variable has +to be set to ``ENABLED`` (see :doc:`/getting_started`) in order to use +this module. + +You may then define your own JavaScript module to define how +references to specific Records should be resolved. The module has to +be located at a directory which is known to CaosDB WebUI; we recommend +``caosdb-webui/src/ext/js``. Set the value of the +``BUILD_EXT_REFERENCES_CUSTOM_RESOLVER`` build variable to the name of +this module. The module has to have a ``resolve`` function which takes +an entity id as its only parameter and returns a ``reference_info`` +object with the resolved custom reference as a ``text`` property. So +the basic structure of the module should look like + +.. code-block:: javascript + + var my_reference_resolver = new function () { + // Has to be called ``resolve`` and has to take exactly one + // string parameter: the id of the referenced entity. + this.resolve = async function (id) { + /* + * find the string that the reference should be resolved to, + * e.g., from the value of the entity's properties. + */ + return {"text": new_reference_text} + } + } + +An example is located in +``caosdb-webui/src/ext/js/person_reference_resolver.js``. It resolves +any reference to a ``Person`` Record to the value of its ``firstname`` +and ``lastname`` properties separated by a space and is active by +default. diff --git a/src/ext/js/person_reference_resover.js b/src/ext/js/person_reference_resover.js new file mode 100644 index 0000000000000000000000000000000000000000..393557354904787f04472585bca0883d64200d86 --- /dev/null +++ b/src/ext/js/person_reference_resover.js @@ -0,0 +1,65 @@ +/* + * ** header v3.0 + * This file is a part of the CaosDB Project. + * + * Copyright (C) 2021 IndiScale GmbH (info@indiscale.com) + * Copyright (C) 2021 Florian Spreckelsen (f.spreckelsen@indiscale.com) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + * + * ** end header + */ + +/** + * @module person_reference + * + * Replace the reference to a Person Record by the values of that + * Record's firstname and lastname properties. + * + * TODO: Make name(s) of person RecordType(s) and names of firstname + * and lastname properties configurable. + */ +var person_reference = new function () { + + var logger = log.getLogger("person_reference"); + + const lastname_prop_name = "lastname" + const firstname_prop_name = "firstname" + const person_rt_name = "Person" + + /** + * Return the name of a person as firstname + lastname + */ + this.get_person_str = function (el) { + var valpr = getProperties(el); + if (valpr == undefined) { + return; + } + return valpr.filter(valprel => + valprel.name.toLowerCase().trim() == + firstname_prop_name.toLowerCase())[0].value + + " " + + valpr.filter(valprel => valprel.name.toLowerCase().trim() == + lastname_prop_name.toLowerCase())[0].value; + } + + this.resolve = async function (id) { + + const entity = (await resolve_references.retrieve(id))[0]; + + if (resolve_references.is_child(entity, person_rt_name)) { + return {"text": person_reference.get_person_str(entity)}; + } + } +} diff --git a/test/core/js/modules/ext_cosmetics.js.js b/test/core/js/modules/ext_cosmetics.js.js new file mode 100644 index 0000000000000000000000000000000000000000..d5d4df7f10a2859bcd7318680d4f6720aedc6127 --- /dev/null +++ b/test/core/js/modules/ext_cosmetics.js.js @@ -0,0 +1,87 @@ +/* + * This file is a part of the CaosDB Project. + * + * Copyright (C) 2021 IndiScale GmbH <info@indiscale.com> + * Copyright (C) 2021 Timm Fitschen <t.fitschen@indiscale.com> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +'use strict'; + +QUnit.module("ext_cosmetics.js", { + before: function (assert) { + cosmetics.init(); + // setup before module + }, + beforeEach: function (assert) { + // setup before each test + }, + afterEach: function (assert) { + // teardown after each test + }, + after: function (assert) { + // teardown after module + } +}); + +QUnit.test("linkify - https", function (assert) { + assert.ok(cosmetics.linkify, "linkify available"); + var test_cases = [ + ["https://link", 1], + ["this is other text https://link", 1], + ["https://link this is other text", 1], + ["this is other text https://link and this as well", 1], + ["this is other text https://link", 1], + ["this is other text https://link and here comes another link https://link and more text", 2], + ]; + for (let test_case of test_cases) { + var text_value = $(`<div class="caosdb-f-property-text-value">${test_case[0]}</div>`); + $(document.body).append(text_value); + assert.equal($(text_value).find("a[href='https://link']").length, 0, "no link present"); + cosmetics.linkify(); + assert.equal($(text_value).find("a[href='https://link']").length, test_case[1], "link is present"); + text_value.remove(); + } +}); + +QUnit.test("linkify - http", function (assert) { + var test_cases = [ + ["http://link", 1], + ["this is other text http://link", 1], + ["http://link this is other text", 1], + ["this is other text http://link and this as well", 1], + ["this is other text http://link", 1], + ["this is other text http://link and here comes another link http://link and more text", 2], + ]; + for (let test_case of test_cases) { + var text_value = $(`<div class="caosdb-f-property-text-value">${test_case[0]}</div>`); + $(document.body).append(text_value); + assert.equal($(text_value).find("a[href='http://link']").length, 0, "no link present"); + cosmetics.linkify(); + assert.equal($(text_value).find("a[href='http://link']").length, test_case[1], "link is present"); + text_value.remove(); + } +}); + +QUnit.test("linkify cut-off (40)", function (assert) { + var test_case = "here is some text https://this.is.a.link/with/more/than/40/characters/ this is more text"; + var text_value = $(`<div class="caosdb-f-property-text-value">${test_case}</div>`); + $(document.body).append(text_value); + assert.equal($(text_value).find("a").length, 0, "no link present"); + cosmetics.linkify(); + assert.equal($(text_value).find("a").length, 1, "link is present"); + assert.equal($(text_value).find("a").text(), "https://this.is.a.link/with/more/th[...] ", "link text has been cut off"); + text_value.remove(); +}); \ No newline at end of file diff --git a/test/core/js/modules/ext_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){