diff --git a/CHANGELOG.md b/CHANGELOG.md index f6a7a87c4c3f83dac53c4a3ba21a983f9c551bbb..04ae51995a9f3f7ec21a4b6a87b9e10ed193f8d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added (for new features, dependecies etc.) +* `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. * Displaying and interacting with the entity state. diff --git a/build.properties.d/00_default.properties b/build.properties.d/00_default.properties index 76ce6c7fa0e2f4af1babf6dc3661fe86e0a7bf18..58d20c521100bbd43e094c2da402abc38ea555bc 100644 --- a/build.properties.d/00_default.properties +++ b/build.properties.d/00_default.properties @@ -127,6 +127,7 @@ MODULE_DEPENDENCIES=( ext_autocomplete.js preview.js ext_references.js + ext_applicable.js ext_table_preview.js ext_xls_download.js query_shortcuts.js diff --git a/src/core/css/webcaosdb.css b/src/core/css/webcaosdb.css index e5cd26401be57fceded902b156aa590f1f9d0a34..b76a5fe9ecc9f6d866dc159429ef7401ac3ddd5d 100644 --- a/src/core/css/webcaosdb.css +++ b/src/core/css/webcaosdb.css @@ -494,7 +494,7 @@ h5 { margin-top: auto; margin-bottom: auto; margin-right: 8px; - height: 30px; + max-height: 30px; } .caosdb-comment-action-item { diff --git a/src/core/css/webcaosdb.less b/src/core/css/webcaosdb.less deleted file mode 100644 index 54e9113df34232234ed6814a4383cf7ba954e121..0000000000000000000000000000000000000000 --- a/src/core/css/webcaosdb.less +++ /dev/null @@ -1,401 +0,0 @@ -/* - * ** header v3.0 - * This file is a part of the CaosDB Project. - * - * Copyright (C) 2018 Research Group Biomedical Physics, - * Max-Planck-Institute for Dynamics and Self-Organization Göttingen - * - * 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 - */ - -@radius_normal: 4px; -@margin_normal: 8px; -@border1: 1px solid #e7e7e7; - -.caosdb-v-show-only-child { - display: none; -} - -.caosdb-v-show-only-child:only-child { - display: initial; -} - -.caosdb-comment-annotation-text h1 { - font-size: 24px; -} - -.caosdb-comment-annotation-text h2 { - font-size: 20px; -} - -.caosdb-comment-annotation-text h3 { - font-size: 18px; -} - -.caosdb-comment-annotation-text h4 { - font-size: 16px; -} - -.caosdb-show-preview-button, .caosdb-hide-preview-button { - position: absolute; - top: 5px; - left: -5px; -} - -.caosdb-entity-heading-attr { - overflow-x: auto; -} - -a.label.caosdb-id-button:hover { - background-color: #5E6762; -} - -.caosdb-entity-preview .caosdb-entity-panel-body { - overflow-y: auto; - max-height: 250px; -} - -.caosdb-preview-carousel-nav > .caosdb-value-list > .btn-group > .btn:first-child { - margin-left: 34px; -} -.caosdb-preview-carousel-nav > .caosdb-value-list > .btn-group > .btn:last-child { - margin-right: 34px; -} - -.caosdb-preview-carousel-nav { - position: relative; -} -.caosdb-query-panel { - padding-top: 20px; - padding-bottom: 20px; -} - -.navbar-light .btn-link:hover { - text-decoration: none; -} - -.navbar-light .btn-link:focus { - text-decoration: none; -} - -.caosdb-property-name { - font-weight: bold; - margin-left: @margin-normal; -} - -.caosdb-square { - position: relative; - overflow: hidden; -} - -.caosdb-square::before { - content: ""; - display: block; - padding-top: 100%; -} - -.caosdb-square-content { - position: absolute; - top: 0px; - right: 0px; - bottom: 0px; - left: 0px; -} - -.caosdb-parent-name { - font-weight: bold; - margin-right: 4px; -} - -/* lists of values */ -/* INLINE (with default scroll-bar) */ -.caosdb-value-list { - overflow-x: auto; -} - -.caosdb-value-list > .btn-group > .btn { - float: none; -} -.caosdb-value-list > .btn-group, -.caosdb-value-list > ol { - display: inline-block; - float: none; - white-space: nowrap; - margin-bottom: 0px; - margin-left: 0px; -} - -.caosdb-value-list > .list-inline > li { - border-left-width: 0px; -} - -.caosdb-value-list > .list-inline > li:first-child { - border-radius: @radius_normal 0px 0px @radius_normal; - border-left-width: 1px; -} - -.caosdb-value-list > .list-inline > li:last-child { - border-radius: 0px @radius_normal @radius_normal 0px; -} - -/* single boolean values */ -.caosdb-boolean-true { - font-weight: bold; - font-size: 90%; - border: 1px solid #bbb; - padding: 2px 8px; - border-radius: 8px; -} - -.caosdb-boolean-false { - .caosdb-boolean-true(); -} - -.caosdb-entity-panel-heading { - border-bottom-left-radius: 3px; - border-bottom-right-radius: 3px; -} - -.caosdb-entity-panel-body { - padding: 0px 15px; -} - -.caosdb-entity-panel-body > :first-child { - margin-top: 15px; -} - -.caosdb-entity-heading-attr-name { - color: #6c6c6c; - font-size: 90%; - margin-right: 0.3em; -} - -.caosdb-subproperty-divider { - margin: 4px 0px; - border-top: 1px solid #dddddd -} - -.caosdb-prop-label { - background-color: #f5f5f5; - font-weight: bold; - padding: 8px 15px; -} - -.caosdb-prop-value { - background-color: #ffffff; - padding: 8px 15px; -} - -.caosdb-prop-list-group .row { - margin-left: 0px; - margin-right: 0px; -} - -.caosdb-prop-list-group>.list-group-item { - padding: 0px -} - -.caosdb-prop-list-group>.list-group-item:first-child .caosdb-prop-label - { - border-top-left-radius: @radius_normal; - border-top-right-radius: @radius_normal; -} - -.caosdb-prop-list-group>.list-group-item:first-child .caosdb-prop-value - { - border-top-right-radius: @radius_normal; -} - -.caosdb-prop-list-group>.list-group-item:last-child .caosdb-prop-label { - border-bottom-left-radius: @radius_normal; -} - -.caosdb-prop-list-group>.list-group-item:last-child .caosdb-prop-value { - border-bottom-left-radius: @radius_normal; - border-bottom-right-radius: @radius_normal; -} - -.caosdb-label-record { - background-color: #F92108; - margin-right: @margin-normal; -} - -.caosdb-label-recordtype { - background-color: #00A32E; - margin-right: @margin-normal; -} - -.caosdb-label-property { - background-color: #496DAB; - margin-right: @margin-normal; -} - -.caosdb-label-file { - background-color: #C92E86; - margin-right: @margin-normal; -} - -.label.caosdb-id-button { - background-color: #4E5752; -} - - -.caosdb-properties-heading { - padding-top: 2px; - padding-bottom: 2px; - background-color: #f5f5f5; - color: #7c7c7c -} - -.caosdb-parents-heading { - .caosdb-properties-heading(); -} - -.caosdb-comments-heading { - .caosdb-properties-heading(); -} - -.caosdb-parent-item { - padding-left: 40px; - text-indent: -40px -} - -.caosdb-unit { - color: #8c8c8c; - font-size: 80%; - margin-left: 0.3em; -} - -.navbar-brand { - display: flex; - align-items: center; -} - -.navbar-brand>img { - padding: 0px 0px; - height: 100% -} - -.caosdb-fs-cwd::before { - content: " > "; -} - -.caosdb-fs-dir>.glyphicon::before { - content: "\e117"; -} - -.caosdb-fs-dir>.glyphicon { - margin-right: @margin-normal; -} - -.caosdb-fs-dir:hover>.glyphicon::before { - content: "\e118"; -} - -.caosdb-fs-file>.glyphicon::before { - content: "\e022"; -} - -.caosdb-fs-file>.glyphicon { - margin-right: @margin-normal; -} - -.caosdb-fs-file:hover>.glyphicon::before { - content: "\e025"; -} - -.caosdb-fs-btn-file { - padding: 0px; - background-color: transparent; - border: 0px; -} - -.caosdb-fs-btn-file:hover .caosdb-label-file { - background-color: #F96EB6; -} - -.caosdb-fs-btn-file:hover .caosdb-label-id { - background-color: #6E8782; -} - -.back-to-top { - cursor: pointer; - position: fixed; - bottom: 10px; - right: 15px; - display: none; - z-index: 1050; -} - -.caosdb-logo { - margin-right: @margin-normal; -} - -.caosdb-comment-action-item { - padding-left: 4px; - padding-right: 4px; - border-left: 1px solid #7c7c7c; -} - -.caosdb-comment-action>.caosdb-comment-action-item:first-child { - border-left: 0px solid #7c7c7c; -} - -.caosdb-pagination { - margin: 5px 15px; -} - -.caosdb-pagination-navbar { - padding-bottom: 5px; - position: fixed; - bottom: 0px; - width: 100%; - border: 0px; - border-top: @border1; - z-index: 1000; - position: fixed; -} - -.caosdb-heading { - color: #5e5e5e; - background-color: #f8f8f8; - border-bottom: @border1; -} - -.caosdb-heading>.container { - padding: 20px 0px; -} - -.spinning { - animation: spin 2s linear infinite; -} - -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - -.flipped-horiz-icon { - transform: scaleX(-1); -} - -.spacer { - margin-left: 12px; -} - -input[type="file"] { - display: none; -} diff --git a/src/core/js/ext_applicable.js b/src/core/js/ext_applicable.js new file mode 100644 index 0000000000000000000000000000000000000000..6e4ae94f742e00e46f6cff609f4217100c9f4292 --- /dev/null +++ b/src/core/js/ext_applicable.js @@ -0,0 +1,342 @@ +/* + * ** 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 + */ + +'use strict'; + +/** + * Useful helpers for is_applicable functionality. + */ +var _helpers = function(getEntityPath) { + + /** + * Check if an entity has a path attribute and one of a set of extensions. + * + * Note: the array of extensions must contain only lower-case strings. + * + * @param {HTMLElement} entity + * @param {string[]} extensions - an array of file extesions, e.g. `jpg`. + * @return {boolean} true iff the entity has a path with one of the + * extensionss. + */ + const path_has_file_extension = function(entity, extensions) { + const path = getEntityPath(entity); + if (path) { + for (var ext of extensions) { + if (path.toLowerCase().endsWith(ext)) { + return true; + } + } + } + return false; + } + + return { + path_has_file_extension: path_has_file_extension + }; +}(getEntityPath) + +var ext_applicable = function($, logger, is_in_view_port, load_config, getEntityPath, connection, helpers, createWaitingNotification) { + + const version = "0.1"; + + /** + * Run through all creators and call the "create" function of the first + * creator which returns true from is_applicable(entity). + * + * @param {HTMLElement} entity + * @param {Creator[]} creators - list of creators + * @return {HTMLElement|String} content - the result of the first matching + * creator. + */ + const root_creator = async function (entity, creators) { + for (let c of creators) { + var is_applicable = false; + try { + is_applicable = await c.is_applicable(entity); + } catch (err) { + logger.error(`error in is_applicable function of creator`, c, err); + continue; + } + if (is_applicable) { + const content = await c.create(entity); + return content; + } + } + return undefined; + } + + var _set_content = function (container, content) { + const _container = $(container); + _container.empty(); + + if (content) { + _container.append(content); + } + } + + /** + * Replace the old content (if any) of the container by the new the content + * (created by the creators). + * + * Dispatch contentReadyEvent or noContentEvent if given. + * + * @param {HTMLElement} container + * @param {HTMLElement|string} [content] + * @param {Event} [contentReadyEvent] + * @param {Event} [noContentEvent] + */ + const set_content = async function (container, content, contentReadyEvent, noContentEvent) { + try { + const wait = createWaitingNotification("Please wait..."); + _set_content(container, wait); + const result = await content; + _set_content(container, result); + if (result && contentReadyEvent) { + container.dispatchEvent(contentReadyEvent); + } else if (!result && noContentEvent) { + container.dispatchEvent(noContentEvent); + } + + } catch (err) { + logger.error(err); + const err_msg = "An error occured while loading this content."; + _set_content(container, err_msg); + } + } + + + /** + * @param {HTMLElement} entity + * @param {get_container_cb} get_container_cb + * @param {Creator[]} creators + * @param {Event} [contentReadyEvent] + * @param {Event} [noContentEvent] + */ + const _root_handler = function (entity, get_container_cb, creators, contentReadyEvent, noContentEvent) { + const _container = get_container_cb(entity); + if (_container) { + const content = root_creator(entity, creators); + set_content(_container, content, contentReadyEvent, noContentEvent); + } + } + + + /** + * The root handler trigger call the root_handler callback on every + * .caosdb-entity-panel and every .caosdb-entity-preview in the viewport. + * + * @param {function} root_handler - the root handler callback. + */ + var root_handler_trigger = function(root_handler) { + var entities = $(".caosdb-entity-panel,.caosdb-entity-preview"); + for (let entity of entities) { + + // TODO viewport + 1000 px for earlier loading + if (is_in_view_port(entity)) { + root_handler(entity); + } + } + } + + + /** + * Initialize the scroll watcher which listens on the scroll event of the + * window and triggers the root handler with a delay after the last + * scroll event. + * + * @param {integer} delay - timeout in milliseconds after the last scroll + * event. After this timeout the trigger is called. + * @param {function} trigger - the root handler callback which is called. + */ + var init_watcher = function(delay, trigger) { + var scroll_timeout = undefined; + $(window).scroll(() => { + if (scroll_timeout) { + clearTimeout(scroll_timeout); + } + scroll_timeout = setTimeout(trigger, delay); + }); + + var preview_timeout = undefined; + + // init watcher on newly loaded entity previews. + window.addEventListener( + preview.previewReadyEvent.type, + () => { + if (preview_timeout) { + clearTimeout(preview_timeout); + } + preview_timeout = setTimeout(trigger, delay); + }, + true); + + // trigger for the first time + trigger(); + }; + + /** + * @callback get_container_cb + * @param {HTMLElement} entity - the entity for which this callback shall + * return the is_applicable container. + * @returns {HTMLElement} child of entity which is a container for the + * is_applicable_app + */ + + /** + * @type {IsApplicableApp} + * @property {IsApplicableConfig} config + * @property {Creator[]} creators + * @property {function} root_handler + */ + + /** + * @type {IsApplicableConfig} + * @property {string|HTMLElement|function} fallback - Fallback content or + * callback if none of the creators are applicable. + * @property {string} version - the version of the configuration which must + * match this module's version. + * @property {CreatorConfig[]} creators - an array of creators. + */ + + /** + * make a fallback creator + * + * @return {Creator} + */ + const _make_fallback_creator = function(fallback) { + if (fallback) { + return { + id: "_generated_fallback_creator", + is_applicable: (entity) => true, // always applicable + create: typeof fallback === "function" ? fallback : (entity) => fallback, + }; + } + return undefined; + } + + const _make_creator = function (c) { + return { + id: c.id, + is_applicable: typeof c.is_applicable === "function" ? + c.is_applicable : eval(c.is_applicable), + create: typeof c.create === "function" ? c.create : eval(c.create) + }; + } + + + /** + * @param {IsApplicableConfig} + * @return {Creator[]} creators + */ + const _make_creators = function(config) { + const creators = []; + for (let c of config.creators) { + creators.push(_make_creator(c)); + } + const fallback_creator = _make_fallback_creator(config.fallback); + if (fallback_creator) { + creators.push(fallback_creator); + } + return creators; + } + + /** + * @type {CreatorConfig} + * @property {string} [id] - a unique id for the creator. optional, for + * debuggin purposes. + * @property {function|string} is_applicable - If this is a string this has + * to be valid javascript! An asynchronous function which accepts one + * parameter, an entity in html representation, and which returns true + * iff this creator is applicable for the given entity. + * @property {string} create - This has to be valid javascript! An + * asynchronous function which accepts one parameter, an entity in html + * representation. It returns a HTMLElement or text node which will be + * shown in the bottom line container iff the creator is applicable. + */ + + /** + * @param {string} app_name - the name of this app. + * @param {get_container_cb} get_container_cb + * @return {get_container_cb} wrapped function which also checks if the + * container has already been filled with the created content. + */ + const _make_get_container_wrapper = function (get_container_cb, app_name) { + const _wrapper = function (entity) { + const container = get_container_cb(entity); + const app_done = $(container).data(app_name); + if(!app_done) { + // mark container as used + $(container).data(app_name, "done"); + return container + } + // don't return the container if already used by this app + return undefined; + } + return _wrapper; + } + + /** + * @param {string} that_version - a version string. + * @throws {Error} if that_version doesn't match this modules version. + */ + const _check_version = function(that_version) { + if(that_version != version) { + throw new Error(`Wrong version in config. Was '${that_version}', should be '${version}'.`); + } + } + + /** + * @param {string} app_name + * @param {IsApplicableConfig} config + * @param {get_container_cb} get_container + * @param {Event} [contentReadyEvent] + * @param {Event} [noContentEvent] + * returns {IsApplicableApp} + */ + const create_is_applicable_app = function(app_name, config, get_container, contentReadyEvent, noContentEvent) { + logger.debug("create_is_applicable_app", config, get_container); + _check_version(config["version"]) + const creators = _make_creators(config) + const get_container_wrapper = _make_get_container_wrapper(get_container, app_name); + + const root_handler = (entity) => _root_handler(entity, get_container_wrapper, creators, contentReadyEvent, noContentEvent); + + if (config.init_watcher) { + init_watcher(config.delay || 500, () => {root_handler_trigger(root_handler);}); + } + return { + config: config, + creators: creators, + root_handler: root_handler, + } + } + + return { + create_is_applicable_app: create_is_applicable_app, + root_handler_trigger: root_handler_trigger, + init_watcher: init_watcher, + helpers: helpers, + version: version, + }; + +}($, log.getLogger("ext_applicable"), resolve_references.is_in_viewport_vertically, load_config, getEntityPath, connection, _helpers, createWaitingNotification); diff --git a/test/core/js/modules/ext_applicable.js.js b/test/core/js/modules/ext_applicable.js.js new file mode 100644 index 0000000000000000000000000000000000000000..596f41c9c9a80e47f4a8ebd8268194af4eba1236 --- /dev/null +++ b/test/core/js/modules/ext_applicable.js.js @@ -0,0 +1,30 @@ +/* + * ** 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 + */ + +'use strict'; + +QUnit.module("ext_applicable", {}); + +QUnit.test("availability", function(assert) { + assert.equal(ext_applicable.version, "0.1"); +});