From 44cf71453b6069a503d5fd8ca1c5bbfa62da0b0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20tom=20W=C3=B6rden?= <h.tomwoerden@indiscale.com> Date: Mon, 14 Dec 2020 15:37:50 +0000 Subject: [PATCH] Show entities at related location --- .gitignore | 2 +- CHANGELOG.md | 7 +- Makefile | 6 +- src/core/js/caosdb.js | 85 +++++ src/core/js/ext_map.js | 483 ++++++++++++++++++++++++----- src/ext/js/fileupload.js | 2 - test/core/js/modules/caosdb.js.js | 87 +++++- test/core/js/modules/ext_map.js.js | 209 ++++++++++--- test/docker/Dockerfile | 7 +- 9 files changed, 762 insertions(+), 126 deletions(-) diff --git a/.gitignore b/.gitignore index ef086126..859153e6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,13 +13,13 @@ /sss_bin /node_modules/ /build +__pycache__ # screen logs screenlog.* xerr.log # extensions - conf/ext test/ext src/ext diff --git a/CHANGELOG.md b/CHANGELOG.md index f2c54f43..6a4911ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,12 +20,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 button (edit_mode). * Plotly preview has an additional parameter for a config object, e.g., for disabling the plotly logo +- The map can now show entities that have no geo location but are related to + entities that have one. This also effects the search from the map. +* `getPropertyValues` function which generates a table of property values from + a xml representation of entities. * After a SELECT statement now also all referenced files can be downloaded. * Automated documentation builds: `make doc` ### Changed (for changes in existing functionality) -- enabled and enhanced autocompletion +* ext_map version bumped to 0.4 +- enabled and enhanced autocompletion * Login form is hidden behind another button. ### Deprecated (for soon-to-be removed features) diff --git a/Makefile b/Makefile index 92f69dfb..1f182e78 100644 --- a/Makefile +++ b/Makefile @@ -139,8 +139,10 @@ install-sss: popd ; \ ./install-sss.sh $(SRC_SSS_DIR) $(SSS_BIN_DIR) -PYTEST ?= pytest-3 +PYTEST ?= pytest +PIP ?= pip3 test-sss: install-sss + $(PIP) freeze $(PYTEST) -vv $(TEST_SSS_DIR) @@ -303,7 +305,7 @@ unzip: for f in $(LIBS_ZIP); do unzip -q -o -d libs $$f; done -PYLINT ?= pylint3 +PYLINT ?= pylint PYTHON_FILES = $(subst $(ROOT_DIR)/,,$(shell find $(ROOT_DIR)/ -iname "*.py")) pylint: $(PYTHON_FILES) for f in $(PYTHON_FILES); do $(PYLINT) -d all -e E,F $$f || exit 1; done diff --git a/src/core/js/caosdb.js b/src/core/js/caosdb.js index e5f875ad..0c63fbe0 100644 --- a/src/core/js/caosdb.js +++ b/src/core/js/caosdb.js @@ -697,6 +697,91 @@ function getProperties(element) { return list; } + +/** + * Construct XPath expression from selectors. + * + * Used by getPropertyValues. + * + * @param {String[][]} selectors + * @return {String[]} XPath expressions. + */ +var _constructXpaths = function (selectors) { + const xpaths = []; + for (let sel of selectors) { + var expr = "Property"; + if (sel[0] == "id") { + expr = ""; + } + for (let i = 0; i < sel.length; i++) { + const segment = sel[i]; + if (segment == "id") { + expr += `@id`; + } else if (segment) { + expr += `[@name='${segment}']`; + } + + if (i+1 < sel.length) { + expr += "//Property" + } + } + xpaths.push(expr); + } + return xpaths; +} + +/** + * Return a table where each row represents an entity and each column a property. + * + * This also works for entities from select queries, where the properties + * are deeply nested, e.g. when each entity references a "Geo Location" + * record which have latitude and longitude properties: + * + * `getPropertyValues(entities, [["Geo Location", "latitude"], ["Geo Location", "longitude"]])` + * + * Use empty strings for selector elements when the property name is irrelevant: + * + * `getPropertyValues(entities, [["", "latitude"], ["", "longitude"]])` + * + * Limitations: + * + * 1. Currently, this implementation assumes that properties (and subproperties + * for that matter) have unique names, entity-wide and do have a LIST + * datatype. + * + * 2. It only handles one of the many special cases, which is "id". Other + * special cases ("name", "description", "unit", etc.) are to be added when + * needed. + * + * @param {XMLElement[]) entities + * @param {String[][]} selectors + * @return {String[][]} A table of the property values for each entity. + */ +var getPropertyValues = function (entities, selectors) { + const entity_iter = entities.evaluate("/Response/Record", entities); + + const table = []; + const xpaths = _constructXpaths(selectors) + + var current_entity = entity_iter.iterateNext(); + while (current_entity) { + const row = []; + for (let expr of xpaths) { + const property = entities.evaluate(expr, current_entity).iterateNext(); + if (typeof property != "undefined" && property != null) { + row.push(property.textContent.trim()); + } else { + row.push(undefined) + } + + } + table.push(row); + current_entity = entity_iter.iterateNext(); + } + + return table; +} + /** * Sets a property with some basic type checking. * diff --git a/src/core/js/ext_map.js b/src/core/js/ext_map.js index 67c7bfe7..d71a1d1a 100644 --- a/src/core/js/ext_map.js +++ b/src/core/js/ext_map.js @@ -25,7 +25,7 @@ /** * @module caosdb_map - * @version 0.3 + * @version 0.4 * * For displaying a geographical map which shows entities at their associated * geolocation. @@ -34,7 +34,7 @@ * `conf/ext/json/ext_map.json` and comply with the {@link MapConfig} type * which is described below. * - * The current version is 0.3. It is not considered to be stable because + * The current version is 0.4. It is not considered to be stable because * implementation of the graticule still is not satisfactory. * * Apart from that the specification of the configuration and the @@ -43,7 +43,7 @@ var caosdb_map = new function () { var logger = log.getLogger("caosdb_map"); - this.version = "0.3"; + this.version = "0.4"; this.dependencies = ["log", { "L": ["latlngGraticule", "Proj"] }, "navbar", "caosdb_utils"]; @@ -84,10 +84,14 @@ var caosdb_map = new function () { /** * The SelectConfig object configures the custom {@link select_handler} * plugin for the Leaflet.js module, especially the query generation (for - * searching for Entities in the selected area). + * searching for Entities in the selected area) and when retrieving + * entities to be shown. * - * The generated query has the pattern <code>FIND {@link query.role} {@link - * query.entity} WITH ...<code>. The dots stand for the area filter here. + * The generated query for a selected area has the pattern + * <code>FIND {@link query.role} {@link query.entity} WITH PATH AREA<code>. + * PATH can be empty or represent a configured path to some entity, e.g. + * `WITH RT1 WITH RT2`. + * AREA stand for the area filter here. * * The default values of the {@link query} result in queries for any Record * in the selected map area. @@ -98,6 +102,8 @@ var caosdb_map = new function () { * are to be searched in the selected ares. * @property {string} [query.entity] The (parent) entity to be searched * for in the area. Defaults to empty string. + * @property {object} [paths] - A dictionary of paths that define from + * which entities the geographic location shall be taken. */ /** @@ -332,6 +338,7 @@ var caosdb_map = new function () { "role": "RECORD", "entity": "", }, + "paths": {}, }, } @@ -373,21 +380,190 @@ var caosdb_map = new function () { */ /** - * Implements {@link mapEntityGetter}. + * Generates a Property Operator Value (POV) expression by chaining the + * provided arguments with "WITH". + * + * @param {string[]} props - array with the names of RecordTypes + * @returns {string} string with the the filter + */ + this._get_with_POV = function (props) { + var pov = "" + for (let p of props) { + pov = pov + ` WITH ${p} `; + } + return pov; + } + + /** + * Generates a Property Operator Value (POV) by joining ids with OR. + * + * @param {number[]} ids - array of ids for the filter + * @returns {string} string with the the filter + */ + this._get_id_POV = function (ids) { + ids = ids.map(x => "id=" + x); + return "WITH " + ids.join(" or ") + } + + /** + * Generates a SELECT query string that applies the provided path of + * properties as POV and as selector + * + * If ids is provided, the condition is not created from the path, but + * from ids. + * + * @param {DataModelConfig} datamodel - datamodel of the entities to be returned. + * @param {string[]} path - array with the names of RecordTypes + * @param {number[]} ids - array of ids for the filter + * @returns {string} query string */ - this._get_current_page_entities = function ( - datamodel, north, south, west, east) { - const container = $(".caosdb-f-main-entities")[0]; + this._get_select_with_path = function (datamodel, path, ids) { + if (typeof datamodel === "undefined") { + throw new Error("Supply the datamodel.") + } + if (typeof path === "undefined" || path.length == 0) { + throw new Error("Supply at least a RecordType.") + } + const recordtype = path[0]; + const props = path.slice(1, path.length) + var selector = props.join(".") + if (selector != "") { + selector = selector + "." + } + var pov = undefined; + if (typeof ids === "undefined") { + pov = (caosdb_map._get_with_POV(props) + + ` WITH ${datamodel.lat} AND ${datamodel.lng}`); + + } else { + pov = caosdb_map._get_id_POV(ids); + } + return `SELECT ${selector}${datamodel.lat},${selector}${datamodel.lng} FROM ENTITY ${recordtype} ${pov} `; + + } + + + /** + * Returns a dictionary where for each top level record in the xmldoc + * The long and lat is assigned. + * + * depth: the depth of the tree including the top level record type: + * e.g. RT1->prop1->prop2->lat/long would be a depth=3 + * + * @param {XMLDocument} xmldoc - xml document containing the entities + * @param {number} depth - the depth at which the properties shall be taken + * @param {DataModelConfig} datamodel - datamodel of the entities to be returned. + * @returns {Object} a dictionary where for each id as key the location ist + * stored as [lat, lng] + */ + this._get_leaf_prop = function (xmldoc, depth, datamodel) { + const paths = [ + ["id"], + // The following creates a list: ["", "", ... (depth times), lat/long] + ("__split__".repeat(depth) + datamodel.lat).split("__split__"), + ("__split__".repeat(depth) + datamodel.lng).split("__split__"), + ]; + const propertyValues = getPropertyValues(xmldoc, paths); + + const leaves = {}; + for (let row of propertyValues) { + leaves[row[0]] = [row[1], row[2]]; + } + return leaves; + } + + /** + * Template for {@link mapEntityGetter}. + * + * This implementation has a single additional parameter which is not + * defined by {@link mapEntityGetter}: + * + * @param {string[]} path - array of strings defining the path to the + * related entity + */ + this._generic_get_current_page_entities = async function ( + datamodel, north, south, west, east, path) { + var container = $(".caosdb-f-main-entities")[0]; + + if (typeof path !== "undefined" && path.length) { + var ids = [] + for (let rec of getEntities(container)) { + ids.push(getEntityID(rec)) + } + if (ids.length) { + const qs = caosdb_map._get_select_with_path(datamodel, path, ids); + let entities = await connection.get("Entity/?query=" + qs); + caosdb_map._set_subprops_at_top(entities, path.length - 1, datamodel); + let results = await transformation.transformEntities(entities); + container = $('<div>').append(results)[0]; + } else { + return []; + } + } + // it is possible, the the page contains entities which do not have + // lat/lng and there doesn't exist any related entity with lat/lng. return caosdb_map.get_map_entities(container, datamodel); } /** - * Implements {@link mapEntityGetter}. + * Returns a top level record entity from xml. + * + * @param {XMLDocument} entities - xml document containing the entities + * @param {number} rec_id - id of the record to be returned + * @returns {XMLDocument} the corresponding record + */ + this._get_toplvl_rec_with_id = function (entities, rec_id) { + let tmp = $(entities).find(`Response >[id='${rec_id}']`); + if (tmp.length != 1) { + throw new Error("There should be exactly one result record. Not " + + tmp.length) + } + return tmp[0]; + } + + /** + * Set the longitude/latitude from subproperties to the top level + * records in the xml and convert everything to html. + * + * @param {XMLDocument} entities - xml document containing the entities + * @param {number} depth - the depth of the path (full: including the first + * recordtype) + */ + this._set_subprops_at_top = function (entities, depth, datamodel) { + var latlong = caosdb_map._get_leaf_prop(entities, depth, datamodel); + + for (let rec_id in latlong) { + let tmp_rec = caosdb_map._get_toplvl_rec_with_id(entities, rec_id); + tmp_rec.append(str2xml(`<Property name="${datamodel.lat}">${latlong[rec_id][0]}</Property>`).firstElementChild); + tmp_rec.append(str2xml(`<Property name="${datamodel.lng}">${latlong[rec_id][1]}</Property>`).firstElementChild); + } + } + + /** + * Template for {@link mapEntityGetter}. + * + * This implementation has a single additional parameter which is not + * defined by {@link mapEntityGetter}: + * + * @param {string[]} path - array of strings defining the path to the + * related entity */ - this._query_all_entities = async function ( - datamodel, north, south, west, east) { - const results = await caosdb_map.query(`FIND ENTITY WITH ${datamodel.lat} AND ${datamodel.lng}`); + this._generic_query_all_entities = async function ( + datamodel, north, south, west, east, path) { + var results = undefined; + if (typeof path !== "undefined" && path.length) { + const qs = caosdb_map._get_select_with_path(datamodel, path); + let entities = await connection.get("Entity/?query=" + qs); + caosdb_map._set_subprops_at_top(entities, path.length - 1, datamodel); + results = await transformation.transformEntities(entities); + } else { + results = await caosdb_map.query( + `FIND ENTITY WITH ${datamodel.lat} AND ${datamodel.lng}`); + } const container = $('<div>').append(results)[0]; + + // As soon as the SELECT query can handle subtyping, the results don't + // have to filtered anymore. return caosdb_map.get_map_entities(container, datamodel); } @@ -410,7 +586,12 @@ var caosdb_map = new function () { const name = caosdb_map.make_entity_name_label(entity); const dms_lat = L.NumberFormatter.toDMS(lat); const dms_lng = L.NumberFormatter.toDMS(lng); - const loc = $(`<div class="small text-muted"> + let extra_loc_hint = ""; + let path = caosdb_map._get_current_path(); + if (path && path.length > 1) { + extra_loc_hint = `<div>Location of related ${path[path.length-1]}<div>`; + } + const loc = $(`<div class="small text-muted">${extra_loc_hint} Lat: ${dms_lat} Lng: ${dms_lng} </div>`); const ret = $('<div/>') @@ -421,6 +602,18 @@ var caosdb_map = new function () { return ret[0]; } + /** + * Returns the path from the config corresponding to the value stored in + * the session storage (i.e. the storage should be updated before calling + * this method if the value changes). + * + * @returns {string[]} path - array of strings defining the path to the + * related entity + */ + this._get_current_path = function () { + return caosdb_map.config.select.paths[sessionStorage["caosdb_map.display_path"]]; + } + /** * Default entities layers configuration with two layers: @@ -430,43 +623,52 @@ var caosdb_map = new function () { * * @type {EntityLayerConfig[]} */ - this._default_entity_layer_config = [{ - "id": "current_page_entities", - "name": "Entities on the current page.", - "description": "Show all entities on the current page.", - "icon": { - html: '<span style="color: #00F; font-size: 20px" class="glyphicon glyphicon-map-marker"></span>', - iconAnchor: [10, 19], - className: "", - }, - "zIndexOffset": 1000, - "datamodel": { - "lat": "latitude", - "lng": "longitude", - "role": "ENTITY", - "entity": "", - }, - "get_entities": this._get_current_page_entities, - "make_popup": this._make_map_popup, - }, { - "id": "all_map_entities", - "name": "All entities", - "description": "Show all entities with coordinates.", - "icon": { - html: '<span style="color: #F00; font-size: 20px" class="glyphicon glyphicon-map-marker"></span>', - iconAnchor: [10, 19], - className: "", + this._default_entity_layer_config = { + "current_page_entities": { + "name": "Entities on the current page.", + "description": "Show all entities on the current page.", + "icon": { + html: '<span style="color: #00F; font-size: 20px" class="glyphicon glyphicon-map-marker"></span>', + iconAnchor: [10, 19], + className: "", + }, + "zIndexOffset": 1000, + "datamodel": { + "lat": "latitude", + "lng": "longitude", + "role": "ENTITY", + "entity": "", + }, + "get_entities": (datamodel, north, south, west, east) => { + let path = caosdb_map._get_current_path() + return caosdb_map._generic_get_current_page_entities( + datamodel, north, south, west, east, path) + }, + "make_popup": this._make_map_popup, }, - "zIndexOffset": 0, - "datamodel": { - "lat": "latitude", - "lng": "longitude", - "role": "ENTITY", - "entity": "", + "all_map_entities": { + "name": "All entities", + "description": "Show all entities with coordinates.", + "icon": { + html: '<span style="color: #F00; font-size: 20px" class="glyphicon glyphicon-map-marker"></span>', + iconAnchor: [10, 19], + className: "", + }, + "zIndexOffset": 0, + "datamodel": { + "lat": "latitude", + "lng": "longitude", + "role": "ENTITY", + "entity": "", + }, + "get_entities": (datamodel, north, south, west, east) => { + let path = caosdb_map._get_current_path() + return caosdb_map._generic_query_all_entities( + datamodel, north, south, west, east, path) + }, + "make_popup": this._make_map_popup, }, - "get_entities": this._query_all_entities, - "make_popup": this._make_map_popup, - }, ]; + }; /** @@ -730,6 +932,22 @@ var caosdb_map = new function () { throw new Error("Could not find view " + id); } + /** + * Reload layers. + */ + this._reload_layers = function () { + caosdb_map._show_load_info() + const promises = [] + for (const layer of caosdb_map.layers) { + promises.push(caosdb_map._fill_layer(layer.layer_group, + caosdb_map._default_entity_layer_config[layer.id])); + } + Promise.all(promises).then((val) => { + caosdb_map._hide_load_info() + }) + } + + /** Initialize the caosdb_map module. * @@ -807,16 +1025,36 @@ var caosdb_map = new function () { view_config); // init entity layers - var layers = this.init_entity_layers(this._default_entity_layer_config); + this.layers = this.init_entity_layers(this._default_entity_layer_config); var layerControl = L.control.layers(); - for (const layer of layers) { + + const promises = [] + for (const layer of this.layers) { + + promises.push(caosdb_map._fill_layer(layer.layer_group, + this._default_entity_layer_config[layer.id])); layerControl.addOverlay(layer.layer_group, layer.chooser_html.outerHTML); layer.layer_group.addTo(this._map); } + Promise.all(promises).then((val) => { + caosdb_map._hide_load_info() + }) layerControl.addTo(this._map); // initialize handlers this.add_select_handler(this._map); + + + this.path_ddm = this._get_path_ddm( + (event) => { + sessionStorage["caosdb_map.display_path"] = event.target.value; + caosdb_map._reload_layers(); + }, + this.config.select.paths + ); + this._map.addControl(this.path_ddm); + + this.add_view_change_handler( this._map, config.views, @@ -858,12 +1096,33 @@ var caosdb_map = new function () { */ this.init_entity_layers = function (configs) { var ret = [] - for (const conf of configs) { - ret.push(this.init_entity_layer(conf)); + for (let name in configs) { + configs[name]["id"] = name; + ret.push(this._init_single_entity_layer(configs[name])); } return ret; } + /** + * Initialize an entity layer. + * + * @param {EntityLayerConfig} config + * @return {_EntityLayer} + */ + this._fill_layer = async function (layer_group, config) { + // in case load is called on a filled layer: clear first + layer_group.clearLayers(); + + var entities = await config.get_entities(config.datamodel); + layer_group.entities = entities; + var markers = caosdb_map.create_entity_markers( + entities, config.datamodel, config.make_popup, + config.zIndexOffset, config.icon); + + for (const marker of markers) { + layer_group.addLayer(marker); + } + }; /** * Initialize an entity layer. @@ -871,24 +1130,11 @@ var caosdb_map = new function () { * @param {EntityLayerConfig} config * @return {_EntityLayer} */ - this.init_entity_layer = function (config) { - logger.trace("enter init_entity_layer", config); + this._init_single_entity_layer = function (config) { + logger.trace("enter _init_single_entity_layer", config); var layer_group = L.layerGroup(); - // load all entities into layer group - var _load = async function (layer_group, config) { - var entities = await config.get_entities(config.datamodel); - var markers = caosdb_map.create_entitiy_markers( - entities, config.datamodel, config.make_popup, - config.zIndexOffset, config.icon); - - for (const marker of markers) { - layer_group.addLayer(marker); - } - }; - _load(layer_group, config); - var ret = { "id": config.id, "active": typeof config.active === "undefined" || config.active, @@ -975,7 +1221,6 @@ var caosdb_map = new function () { L.Handler.extend(this.select_handler); } - /** * Show the query panel if not visible, collapse the query shortcuts * if visible and fill the query string into the text input of the @@ -1048,9 +1293,18 @@ var caosdb_map = new function () { this.generate_query_from_bounds = function (north, south, west, east) { const role = this.config.select.query.role; - const entity = this.config.select.query.entity; + var entity = this.config.select.query.entity; const lat = this.config.datamodel.lat; const lng = this.config.datamodel.lng; + let path = caosdb_map._get_current_path(); + if (path && path.length > 0 && entity == "") { + entity = path[0]; + } + var additional_path = "" + if (path && path.length > 1) { + additional_path = caosdb_map._get_with_POV( + path.slice(1, path.length)) + } const query_filter = " ( " + lat + " < '" + north + "' AND " + lat + @@ -1058,7 +1312,7 @@ var caosdb_map = new function () { "' AND " + lng + " < '" + east + "' ) "; - const query = "FIND " + role + " " + entity + + const query = "FIND " + role + " " + entity + additional_path + " WITH " + query_filter; return query } @@ -1201,7 +1455,7 @@ var caosdb_map = new function () { const entity_on_page = $(`#${id}`).length > 0; const href = entity_on_page ? `#${id}` : connection.getBasePath() + `Entity/${id}` - const link_title = entity_on_page ? "Jump to this entitiy." : "Browse to this entity."; + const link_title = entity_on_page ? "Jump to this entity." : "Browse to this entity."; const link = $(`<a title="${link_title}" href="${href}"/>`) .addClass("pull-right") .append(`<span class="glyphicon glyphicon-share-alt"/></a>`); @@ -1239,8 +1493,8 @@ var caosdb_map = new function () { * @param {DivIcon_options} icon_options * @returns {L.Marker[]} an array of markers for the map. */ - this.create_entitiy_markers = function (entities, datamodel, make_popup, zIndexOffset, icon_options) { - logger.trace("enter create_entitiy_markers", entities, datamodel, zIndexOffset, icon_options); + this.create_entity_markers = function (entities, datamodel, make_popup, zIndexOffset, icon_options) { + logger.trace("enter create_entity_markers", entities, datamodel, zIndexOffset, icon_options); var ret = [] for (const map_entity of entities) { @@ -1440,6 +1694,83 @@ var caosdb_map = new function () { }, } + /** + * Shows the loading information div of the map + */ + this._show_load_info = function () { + $(".caosdb-f-map-loading").attr("style", "display:inherit"); + } + + /** + * Hides the loading information div of the map + */ + this._hide_load_info = function () { + $(".caosdb-f-map-loading").attr("style", "display:None"); + } + + /** + * Return a new leaflet control for setting paths to use for geo location + * + * @param {function} callback - a callback applies the effect of a + * changed path + * @returns {L.Control} the drop down menu button. + */ + this._get_path_ddm = function (callback, paths) { + + // TODO flatten the structure of the code and possibly merge it with the query_button code. + var path_ddm = L.Control.extend({ + options: { + position: "bottomright" + }, + + onAdd: function (m) { + return this.button; + }, + + button: function () { + // TODO refactor to make_map_control function + var button = L.DomUtil + .create("div", + "leaflet-bar leaflet-control leaflet-control-custom" + ); + button.title = `Show the location of related entities. +By default ('Same Entity') entities are shown that have +a geographic location. The other options allow to show +entities on the map using the location of a related +entity.`; + button.style.backgroundColor = "white"; + button.style.textAlign = "center"; + // Distance to zoom buttons: + button.style.marginTop = "10px"; + // TODO implement helper for pictures + let tmp_html = ('<div class="caosdb-f-map-loading" style="display:inherit">Loading Entities...</div><select><option value="same">Same Entity</option>'); + for (let pa in paths) { + tmp_html += `<option value="${pa}">${pa}</option>`; + } + tmp_html += '</select>'; + button.innerHTML = tmp_html; + const select = $(button).find('select'); + select.on("change", callback); + + const current_path = sessionStorage["caosdb_map.display_path"] || "same"; + sessionStorage["caosdb_map.display_path"] = current_path; + select[0].value = current_path + + $(button).on("mousedown", ( + event) => { + event + .stopPropagation(); + }); + $(button).on("mouseup", ( + event) => { + event + .stopPropagation(); + }); + return button; + }(), + }); + return new path_ddm(); + } /** * Plug-in for leaflet which lets the user select an area in the map @@ -1898,4 +2229,4 @@ var caosdb_map = new function () { $(document).ready(function () { caosdb_modules.register(caosdb_map); -}); +}); \ No newline at end of file diff --git a/src/ext/js/fileupload.js b/src/ext/js/fileupload.js index e21ccf7f..1c069f2a 100644 --- a/src/ext/js/fileupload.js +++ b/src/ext/js/fileupload.js @@ -235,8 +235,6 @@ var fileupload = new function() { } this.init = function() { - fileupload.debug("init"); - // add global listener for start_edit event document.body.addEventListener(edit_mode.start_edit.type, function(e) { $(e.target).find(".caosdb-properties .caosdb-f-entity-property").each(function(idx) { diff --git a/test/core/js/modules/caosdb.js.js b/test/core/js/modules/caosdb.js.js index d3e9b4a2..20dc4b4e 100644 --- a/test/core/js/modules/caosdb.js.js +++ b/test/core/js/modules/caosdb.js.js @@ -17,7 +17,7 @@ QUnit.module("caosdb.js", { }, err => {console.log(err);}); }, - + before: function(assert) { var done = assert.async(3); this.setTestDocument("x", done, ` @@ -476,6 +476,90 @@ QUnit.test("unset_entity_references", function(assert) { }); +QUnit.test("_constructXpaths", function (assert) { + assert.propEqual( + _constructXpaths([["id"], ["longitude"], ["latitude"]]), + ["@id", "Property[@name='longitude']", "Property[@name='latitude']"] + ); + assert.propEqual( + _constructXpaths([["Geo Location", "longitude"], ["latitude"]]), + ["Property[@name='Geo Location']//Property[@name='longitude']", "Property[@name='latitude']"] + ); + assert.propEqual( + _constructXpaths([["", "longitude"], ["latitude"]]), + ["Property//Property[@name='longitude']", "Property[@name='latitude']"] + ); + assert.propEqual( + _constructXpaths([["", "Geo Location", "", "longitude"]]), + ["Property//Property[@name='Geo Location']//Property//Property[@name='longitude']"] + ); +}); + + +QUnit.test("getPropertyValues", function (assert) { + const test_response = str2xml(` +<Response srid="851d063d-121b-4b67-98d4-6e5ad4d31e72" timestamp="1606214042274" baseuri="https://localhost:10443" count="8"> + <Query string="select Campaign.responsible.firstname from icecore" results="8"> + <ParseTree>(cq select (prop_sel (prop_subsel (selector_txt C a m p a i g n) . (prop_subsel (selector_txt r e s p o n s i b l e) . (prop_subsel (selector_txt f i r s t n a m e ))))) from (entity icecore) <EOF>)</ParseTree> + <Role/> + <Entity>icecore</Entity> + <Selection> + <Selector name="Campaign.responsible.firstname"/> + </Selection> + </Query> + <Record id="6525" name="Test_IceCore_1"> + <Permissions/> + <Property datatype="Campaign" id="6430" name="Campaign"> + <Record id="6516" name="Test-2020_Camp1"> + <Permissions/> + <Property datatype="REFERENCE" id="168" name="responsible"> + <Record id="6515" name="Test_Scientist"> + <Permissions/> + <Property datatype="DOUBLE" id="151" name="latitude" importance="FIX"> + 1.34 + <Permissions/> + </Property> + <Property datatype="DOUBLE" id="151" name="longitude" importance="FIX"> + 2 + <Permissions/> + </Property> + </Record> + <Permissions/> + </Property> + </Record> + <Permissions/> + </Property> + </Record> + <Record id="6526" name="Test_IceCore_2"> + <Permissions/> + <Property datatype="Campaign" id="6430" name="Campaign"> + <Record id="6516" name="Test-2020_Camp1"> + <Permissions/> + <Property datatype="REFERENCE" id="168" name="responsible"> + <Record id="6515" name="Test_Scientist"> + <Permissions/> + <Property datatype="DOUBLE" id="151" name="latitude" importance="FIX"> + 3 + <Permissions/> + </Property> + <Property datatype="DOUBLE" id="151" name="longitude" importance="FIX"> + 4.8345 + <Permissions/> + </Property> + </Record> + <Permissions/> + </Property> + </Record> + <Permissions/> + </Property> + </Record> +</Response>`); + + assert.propEqual( + getPropertyValues(test_response, [["id"], ["", "latitude"],["", "longitude"]]), + [["6525" ,"1.34", "2"], ["6526", "3", "4.8345"]]); +}); + // Test for bug 103 // If role is File when creating XML for entities, checksum, path and size must be given. QUnit.test("unset_file_attributes", function(assert) { @@ -497,4 +581,3 @@ QUnit.test("unset_file_attributes", function(assert) { undefined); assert.equal(xml2str(res3), "<File id=\"103\" name=\"test\" path=\"testfile.txt\" checksum=\"blablabla\" size=\"0\"/>"); }); - diff --git a/test/core/js/modules/ext_map.js.js b/test/core/js/modules/ext_map.js.js index 9d068ad1..d14d7ebf 100644 --- a/test/core/js/modules/ext_map.js.js +++ b/test/core/js/modules/ext_map.js.js @@ -23,10 +23,13 @@ 'use strict'; QUnit.module("ext_map.js", { - before: function(assert) { + before: function (assert) { var lat = "latitude"; var lng = "longitude"; - this.datamodel = { lat: lat, lng: lng }; + this.datamodel = { + lat: lat, + lng: lng + }; this.test_map_entity = ` <div class="caosdb-entity-panel caosdb-properties"> <div class="caosdb-id">1234</div> @@ -42,22 +45,22 @@ QUnit.module("ext_map.js", { </div> </div>`; }, - beforeEach: function(assert) { + beforeEach: function (assert) { sessionStorage.removeItem("caosdb_map.view"); } }); -QUnit.test("availability", function(assert) { - assert.equal(caosdb_map.version, "0.3", "test version"); +QUnit.test("availability", function (assert) { + assert.equal(caosdb_map.version, "0.4", "test version"); assert.ok(caosdb_map.init, "init available"); }); -QUnit.test("default config", function(assert) { +QUnit.test("default config", function (assert) { assert.ok(caosdb_map._default_config); assert.equal(caosdb_map._default_config.version, caosdb_map.version, "version"); }); -QUnit.test("load_config", async function(assert) { +QUnit.test("load_config", async function (assert) { assert.ok(caosdb_map.load_config, "available"); var config = await caosdb_map.load_config("non_existing.json"); assert.ok(config, "returns something"); @@ -65,39 +68,43 @@ QUnit.test("load_config", async function(assert) { assert.equal(config.views[0].id, "UNCONFIGURED", "view has id 'UNCONFIGURED'."); }); -QUnit.test("check_config", function(assert) { +QUnit.test("check_config", function (assert) { assert.ok(caosdb_map.check_config(caosdb_map._default_config), "default config ok"); - assert.throws(()=>caosdb_map.check_config({"version": "wrong version",}), /The version of the configuration does not match the version of this implementation of the caosdb_map module. Should be '.*', was 'wrong version'./, "wrong version"); + assert.throws(() => caosdb_map.check_config({ + "version": "wrong version", + }), /The version of the configuration does not match the version of this implementation of the caosdb_map module. Should be '.*', was 'wrong version'./, "wrong version"); }); -QUnit.test("check dependencies", function(assert) { +QUnit.test("check dependencies", function (assert) { assert.ok(caosdb_map.check_dependencies, "available"); - assert.propEqual(caosdb_map.dependencies, ["log", {"L": ["latlngGraticule", "Proj"]}, "navbar", "caosdb_utils"]); + assert.propEqual(caosdb_map.dependencies, ["log", { + "L": ["latlngGraticule", "Proj"] + }, "navbar", "caosdb_utils"]); assert.ok(caosdb_map.check_dependencies(), "deps available"); }); -QUnit.test("create_toggle_map_button", function(assert) { +QUnit.test("create_toggle_map_button", function (assert) { assert.ok(caosdb_map.create_toggle_map_button, "available"); var button = caosdb_map.create_toggle_map_button(); assert.equal(button.tagName, "BUTTON", "is button"); - assert.ok($(button).hasClass("caosdb-f-toggle-map-button"),"has caosdb-f-toggle-map-button class"); + assert.ok($(button).hasClass("caosdb-f-toggle-map-button"), "has caosdb-f-toggle-map-button class"); assert.equal($(button).text(), "Map", "button says 'Map'"); // set other content: button = caosdb_map.create_toggle_map_button("Karte"); assert.equal(button.tagName, "BUTTON", "is button"); - assert.ok($(button).hasClass("caosdb-f-toggle-map-button"),"has caosdb-f-toggle-map-button class"); + assert.ok($(button).hasClass("caosdb-f-toggle-map-button"), "has caosdb-f-toggle-map-button class"); assert.equal($(button).text(), "Karte", "button says 'Karte'"); }); -QUnit.test("bind_toggle_map", function(assert) { +QUnit.test("bind_toggle_map", function (assert) { let button = $("<button/>")[0]; let done = assert.async(); assert.ok(caosdb_map.bind_toggle_map, "available"); - assert.throws(()=>caosdb_map.bind_toggle_map(button), /parameter 'toggle_cb'.* was undefined/, "no function throws"); - assert.throws(()=>caosdb_map.bind_toggle_map("test", ()=>{}), /parameter 'button'.* was string/, "string button throws"); + assert.throws(() => caosdb_map.bind_toggle_map(button), /parameter 'toggle_cb'.* was undefined/, "no function throws"); + assert.throws(() => caosdb_map.bind_toggle_map("test", () => {}), /parameter 'button'.* was string/, "string button throws"); assert.equal(caosdb_map.bind_toggle_map(button, done), button, "call returns button"); // button click calls 'done' @@ -105,12 +112,12 @@ QUnit.test("bind_toggle_map", function(assert) { }); -QUnit.test("create_map", function(assert) { +QUnit.test("create_map", function (assert) { assert.equal(typeof caosdb_map.create_map_view, "function", "function available"); }); -QUnit.test("create_map_panel", function(assert) { +QUnit.test("create_map_panel", function (assert) { assert.ok(caosdb_map.create_map_panel, "available"); let panel = caosdb_map.create_map_panel(); assert.equal(panel.tagName, "DIV", "is div"); @@ -118,9 +125,11 @@ QUnit.test("create_map_panel", function(assert) { assert.ok($(panel).hasClass("container"), "has class container"); }); -QUnit.test("create_map_view", function(assert) { - var view_config = $.extend(true, {}, caosdb_map._unconfigured_views[0], - {"select": true, "view_change": true}); +QUnit.test("create_map_view", function (assert) { + var view_config = $.extend(true, {}, caosdb_map._unconfigured_views[0], { + "select": true, + "view_change": true + }); var map_panel = $("<div/>"); var map = caosdb_map.create_map_view(map_panel[0], view_config); @@ -144,7 +153,7 @@ QUnit.test("create_map_view", function(assert) { map.remove(); // test with special crs: - view_config["crs"] = { + view_config["crs"] = { "code": "EPSG:3995", "proj4def": "+proj=stere +lat_0=90 +lat_ts=71 +lon_0=0 +k=1 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs", "options": { @@ -163,7 +172,7 @@ QUnit.test("create_map_view", function(assert) { }); -QUnit.test("get_map_entities", function(assert) { +QUnit.test("get_map_entities", function (assert) { var datamodel = this.datamodel; var container = $('<div/>').append(this.test_map_entity); var map_objects = caosdb_map.get_map_entities(container[0], datamodel); @@ -171,12 +180,12 @@ QUnit.test("get_map_entities", function(assert) { }); -QUnit.test("create_entitiy_markers", function(assert) { +QUnit.test("create_entity_markers", function (assert) { var datamodel = this.datamodel; var entities = $(this.test_map_entity).toArray(); // w/o popup - var markers = caosdb_map.create_entitiy_markers(entities, datamodel); + var markers = caosdb_map.create_entity_markers(entities, datamodel); assert.equal(markers.length, 1, "has one marker"); assert.ok(markers[0] instanceof L.Marker, "is marker"); var latlng = markers[0]._latlng; @@ -185,30 +194,32 @@ QUnit.test("create_entitiy_markers", function(assert) { assert.notOk(markers[0].getPopup(), "no popup"); // with popup - var markers = caosdb_map.create_entitiy_markers(entities, datamodel, ()=>"popup"); + var markers = caosdb_map.create_entity_markers(entities, datamodel, () => "popup"); assert.ok(markers[0].getPopup(), "has popup"); }); -QUnit.test("_add_current_page_entities", function(assert) { +QUnit.test("_add_current_page_entities", async function (assert) { var datamodel = this.datamodel; var layerGroup = L.layerGroup(); var container = $('<div class="caosdb-f-main-entities"/>').append(this.test_map_entity); $("body").append(container); assert.equal(layerGroup.getLayers().length, 0, "no layer"); - var cpe = caosdb_map._get_current_page_entities(datamodel, undefined, undefined, undefined, undefined); + var cpe = await caosdb_map._generic_get_current_page_entities(datamodel, undefined, undefined, undefined, undefined, undefined); assert.equal(cpe.length, 1, "has one entity"); container.remove(); }); -QUnit.test("make_layer_chooser_html", function(assert) { - var test_conf = { "id": "test_id", +QUnit.test("make_layer_chooser_html", function (assert) { + var test_conf = { + "id": "test_id", "name": "test name", "description": "test description", - "icon": { "html": "<span>ICON</span>", + "icon": { + "html": "<span>ICON</span>", }, }; @@ -217,19 +228,139 @@ QUnit.test("make_layer_chooser_html", function(assert) { assert.equal($(layer_chooser).attr("title"), "test description", "description set as title"); }); -QUnit.test("init_entity_layer", function(assert) { - var done = assert.async(); - var test_conf = { "id": "test_id", +QUnit.test("_init_single_entity_layer", function (assert) { + var test_conf = { + "id": "test_id", "name": "test name", "description": "test description", - "get_entities": async function() {done(); return []}, - "icon": { "html": "<span>ICON</span>", + "icon": { + "html": "<span>ICON</span>", }, } - var entityLayer= caosdb_map.init_entity_layer(test_conf); + var entityLayer = caosdb_map._init_single_entity_layer(test_conf); assert.equal(entityLayer.id, test_conf.id, "id"); assert.equal(entityLayer.active, true, "is active"); assert.ok(entityLayer.chooser_html instanceof HTMLElement, "chooser_html is HTMLElement"); - assert.equal(entityLayer.layer_group.getLayers().length, 0 , "empty layergroup"); + assert.equal(entityLayer.layer_group.getLayers().length, 0, "empty layergroup"); +}); + +QUnit.test("_get_with_POV ", function (assert) { + assert.equal(caosdb_map._get_with_POV( + []), "", "no POV"); + assert.equal(caosdb_map._get_with_POV( + ["lol"]), " WITH lol ", "single POV"); + assert.equal(caosdb_map._get_with_POV( + ["lol", "hi"]), " WITH lol WITH hi ", "with two POV"); +}); + + +QUnit.test("_get_select_with_path ", function (assert) { + assert.throws(() => caosdb_map._get_select_with_path(), /Supply the datamodel./, "missing datamodel"); + assert.throws(() => caosdb_map._get_select_with_path(this.datamodel, []), /Supply at least a RecordType./, "missing value"); + assert.equal(caosdb_map._get_select_with_path( + this.datamodel, + ["RealRT"]), "SELECT latitude,longitude FROM ENTITY RealRT WITH latitude AND longitude ", "RT only"); + assert.equal(caosdb_map._get_select_with_path( + this.datamodel, + ["RealRT", "prop1"]), "SELECT prop1.latitude,prop1.longitude FROM ENTITY RealRT WITH prop1 WITH latitude AND longitude ", "RT with one prop"); + assert.equal(caosdb_map._get_select_with_path( + this.datamodel, + ["RealRT", "prop1", "prop2"]), "SELECT prop1.prop2.latitude,prop1.prop2.longitude FROM ENTITY RealRT WITH prop1 WITH prop2 WITH latitude AND longitude ", "RT with two props"); +}); + + +QUnit.test("_get_leaf_prop", async function (assert) { + const test_response = str2xml(` +<Response srid="851d063d-121b-4b67-98d4-6e5ad4d31e72" timestamp="1606214042274" baseuri="https://localhost:10443" count="8"> + <Query string="select Campaign.responsible.firstname from icecore" results="8"> + <ParseTree>(cq select (prop_sel (prop_subsel (selector_txt C a m p a i g n) . (prop_subsel (selector_txt r e s p o n s i b l e) . (prop_subsel (selector_txt f i r s t n a m e ))))) from (entity icecore) <EOF>)</ParseTree> + <Role/> + <Entity>icecore</Entity> + <Selection> + <Selector name="Campaign.responsible.firstname"/> + </Selection> + </Query> + <Record id="6525" name="Test_IceCore_1"> + <Permissions/> + <Property datatype="Campaign" id="6430" name="Campaign"> + <Record id="6516" name="Test-2020_Camp1"> + <Permissions/> + <Property datatype="REFERENCE" id="168" name="responsible"> + <Record id="6515" name="Test_Scientist"> + <Permissions/> + <Property datatype="DOUBLE" id="151" name="latitude" importance="FIX"> + 1.34 + <Permissions/> + </Property> + <Property datatype="DOUBLE" id="151" name="longitude" importance="FIX"> + 2 + <Permissions/> + </Property> + </Record> + <Permissions/> + </Property> + </Record> + <Permissions/> + </Property> + </Record> + <Record id="6526" name="Test_IceCore_2"> + <Permissions/> + <Property datatype="Campaign" id="6430" name="Campaign"> + <Record id="6516" name="Test-2020_Camp1"> + <Permissions/> + <Property datatype="REFERENCE" id="168" name="responsible"> + <Record id="6515" name="Test_Scientist"> + <Permissions/> + <Property datatype="DOUBLE" id="151" name="latitude" importance="FIX"> + 3 + <Permissions/> + </Property> + <Property datatype="DOUBLE" id="151" name="longitude" importance="FIX"> + 4.8345 + <Permissions/> + </Property> + </Record> + <Permissions/> + </Property> + </Record> + <Permissions/> + </Property> + </Record> +</Response>`); + var leaves = caosdb_map._get_leaf_prop(test_response, 2, this.datamodel) + + assert.equal(Object.keys(leaves).length, 2, "number of records"); + assert.notEqual(typeof leaves["6525"], "undefined", "has entity id"); + assert.deepEqual(leaves["6525"], ["1.34", "2"]); + assert.deepEqual(leaves["6526"], ["3", "4.8345"], "long/lat in second rec"); + + assert.equal( + caosdb_map._get_toplvl_rec_with_id(test_response, "6526")["id"], + "6526", + "number of records"); + + caosdb_map._set_subprops_at_top( + test_response, 2, this.datamodel, { + "6526": [1.234, 5.67] + }) + assert.equal($(test_response).find(`[name='longitude']`).length, + 4, + "number lng props"); + assert.equal($(test_response).find(`[name='latitude']`).length, + 4, + "number lat props"); + // after transforming, the long/lat props should be accessible + var html_ents = await transformation.transformEntities(test_response); + assert.equal( + getProperty(html_ents[0], "longitude"), + "2", + "longitude of first rec"); + +}); + +QUnit.test("_get_id_POV", function (assert) { + assert.equal(caosdb_map._get_id_POV([]), "WITH ", "no POV"); + assert.equal(caosdb_map._get_id_POV([5]), "WITH id=5", "one id"); + assert.equal(caosdb_map._get_id_POV([5, 6]), "WITH id=5 or id=6", "two ids"); }); diff --git a/test/docker/Dockerfile b/test/docker/Dockerfile index 7340ee5a..8eff9aae 100644 --- a/test/docker/Dockerfile +++ b/test/docker/Dockerfile @@ -2,13 +2,14 @@ FROM debian:10 RUN echo "deb http://deb.debian.org/debian buster-backports main" >> /etc/apt/sources.list \ && apt-get update \ && apt-get install -y \ - firefox-esr gettext-base pylint3 python3-pip \ - python3-httpbin git curl x11-apps xvfb unzip python3-pytest \ + firefox-esr gettext-base python3-pip \ + python3-httpbin git curl x11-apps xvfb unzip \ && apt-get install -y -t buster-backports \ npm +RUN pip3 install pylint pytest RUN pip3 install caosdb -RUN pip3 install pandas xlrd +RUN pip3 install pandas xlrd==1.2.0 RUN pip3 install git+https://gitlab.com/caosdb/caosdb-advanced-user-tools.git@dev # For automatic documentation RUN npm install -g jsdoc -- GitLab