From cb00d4dc7e83c053a7700120388c9bbd3e18e98f Mon Sep 17 00:00:00 2001 From: Stefan Forsgren Date: Wed, 17 Apr 2024 08:29:40 +0200 Subject: [PATCH] Moved attchments and related tables from featureinfo to SelectedItem --- src/featureinfo.js | 146 ++++++------------------------- src/getattributes.js | 170 ++++++++++++++++++++++++++++++++++++- src/getfeatureinfo.js | 33 +++---- src/models/SelectedItem.js | 25 +++++- 4 files changed, 225 insertions(+), 149 deletions(-) diff --git a/src/featureinfo.js b/src/featureinfo.js index d8b835d45..8051add86 100644 --- a/src/featureinfo.js +++ b/src/featureinfo.js @@ -12,9 +12,7 @@ import StyleTypes from './style/styletypes'; import getFeatureInfo from './getfeatureinfo'; import replacer from './utils/replacer'; import SelectedItem from './models/SelectedItem'; -import attachmentclient from './utils/attachmentclient'; -import getAttributes, { getContent, featureinfotemplates } from './getattributes'; -import relatedtables from './utils/relatedtables'; +import { getContent, featureinfotemplates } from './getattributes'; const styleTypes = StyleTypes(); @@ -261,6 +259,12 @@ const Featureinfo = function Featureinfo(options = {}) { return hitTolerance; }; + /** + * Registers a custom attribute formatter function + * @param {any} attributeType Name of your brand new attribute type. Must match the first key specified in attributes configuration + * @param {any} fn A function that thaes argumnets (feature, attribute, attributes, map) and returns a HTML string or HTMLElement + * @returns A reference to the getContent function. + */ const addAttributeType = function addAttributeType(attributeType, fn) { getContent[attributeType] = fn; return getContent; @@ -301,113 +305,6 @@ const Featureinfo = function Featureinfo(options = {}) { textHtmlHandler = func; }; - /** - * Creates temporary attributes on a feature in order for featureinfo to display attributes from related tables and - * display attachments as links. Recursively adds attributes to related features in order to support multi level relations. - * In order to do so, attributes are also added to the related features. - * The hoistedAttributes array can be used to remove all attributes that have been added. - * @param {any} parentLayer The layer that holds the feature - * @param {any} parentFeature The feature to add attributes to - * @param {any} hoistedAttributes An existing array that is populated with the added attributes. - */ - async function hoistRelatedAttributes(parentLayer, parentFeature, hoistedAttributes) { - // This function is async and called recursively, DO NOT USE forEach!!! (It won't work) - - let dirty = false; - // Add attachments first but only if configured for attribute hoisting - const attachmentsConf = parentLayer.get('attachments'); - if (attachmentsConf && attachmentsConf.groups.some(g => g.linkAttribute || g.fileNameAttribute)) { - const ac = attachmentclient(parentLayer); - const attachments = await ac.getAttachments(parentFeature); - for (let i = 0; i < ac.getGroups().length; i += 1) { - const currAttrib = ac.getGroups()[i]; - let val = ''; - let texts = ''; - if (attachments.has(currAttrib.name)) { - const group = attachments.get(currAttrib.name); - val = group.map(g => g.url).join(';'); - texts = group.map(g => g.filename).join(';'); - } - if (currAttrib.linkAttribute) { - parentFeature.set(currAttrib.linkAttribute, val); - hoistedAttributes.push({ feature: parentFeature, attrib: currAttrib.linkAttribute }); - dirty = true; - } - if (currAttrib.fileNameAttribute) { - hoistedAttributes.push({ feature: parentFeature, attrib: currAttrib.fileNameAttribute }); - parentFeature.set(currAttrib.fileNameAttribute, texts); - dirty = true; - } - } - } - - // Add related layers - const relatedLayersConfig = relatedtables.getConfig(parentLayer); - if (relatedLayersConfig) { - for (let i = 0; i < relatedLayersConfig.length; i += 1) { - const layerConfig = relatedLayersConfig[i]; - - if (layerConfig.promoteAttribs) { - // First recurse our children so we can propagate from n-level to top level - const childLayer = viewer.getLayer(layerConfig.layerName); - // Function is recursice, we have to await - // eslint-disable-next-line no-await-in-loop - const childFeatures = await relatedtables.getChildFeatures(parentLayer, parentFeature, childLayer); - for (let jx = 0; jx < childFeatures.length; jx += 1) { - const childFeature = childFeatures[jx]; - // So here comes the infamous recursive call ... - // Function is recursice, we have to await - // eslint-disable-next-line no-await-in-loop - await hoistRelatedAttributes(childLayer, childFeature, hoistedAttributes); - } - - // Then actually hoist some related attributes - for (let j = 0; j < layerConfig.promoteAttribs.length; j += 1) { - const currAttribConf = layerConfig.promoteAttribs[j]; - const resarray = []; - childFeatures.forEach(child => { - // Collect the attributes from all children - // Here one could imagine supporting more attribute types, but html is pretty simple and powerful - if (currAttribConf.html) { - const val = replacer.replace(currAttribConf.html, child.getProperties()); - resarray.push(val); - } - }); - // Then actually aggregate them. Its a two step operation so in the future we could support more aggregate functions, like min(), max() etc - // and also to avoid appending manually and handle that pesky separator on last element. - const sep = currAttribConf.separator ? currAttribConf.separator : ''; - const resaggregate = resarray.join(sep); - parentFeature.set(currAttribConf.parentName, resaggregate); - hoistedAttributes.push({ feature: parentFeature, attrib: currAttribConf.parentName }); - dirty = true; - } - } - } - } - // Only returns if top level is dirty. We don't build content for related objects. - return dirty; - } - - /** - * Adds content from related tables and attachments. - * @param {any} item - * @param {any} hoistedAttributes - */ - async function addRelatedContent(item) { - const hoistedAttributes = []; - const updated = await hoistRelatedAttributes(item.getLayer(), item.getFeature(), hoistedAttributes); - if (updated) { - // Update content as the pseudo attributes have changed - // Ideally this should have been made before SelectedItem was created, but that changes so much - // in the code flow as the getAttachment is async-ish - item.setContent(getAttributes(item.getFeature(), item.getLayer(), viewer.getMap())); - } - // Remove all temporary added attributes. They mess up saving edits as there are no such fields in db. - hoistedAttributes.forEach(hoist => { - hoist.feature.unset(hoist.attrib, true); - }); - } - /** * Internal helper that performs the actual rendering * @param {any} identifyItems @@ -554,21 +451,28 @@ const Featureinfo = function Featureinfo(options = {}) { }; /** * Renders the feature info window. Consider using showInfo instead if calling using api. + * Does not add async content as SelectedItem is displayed as-is, which means user can inject just about anything + * in SelectedItem.Content * @param {any} identifyItems Array of SelectedItems * @param {any} target Name of infoWindow type * @param {any} coordinate Coordinate where to show pop up. * @param {any} opts Additional options. Supported options are : ignorePan, disable auto pan to popup overlay. */ const render = function render(identifyItems, target, coordinate, opts = {}) { - // Append attachments (if any) to the SelectedItems + doRender(identifyItems, target, coordinate, opts.ignorePan); + }; + /** + * Renders the selectedItems after adding async content. Not actually defined as async as it is part of a sync call chain, + * which no one awaits. + * @param {any} identifyItems Array of SelectedItems + * @param {any} target Name of infoWindow type + * @param {any} coordinate Coordinate where to show pop up. + * @param {any} opts Additional options. Supported options are : ignorePan, disable auto pan to popup overlay. + */ + function renderInternal(identifyItems, target, coordinate, opts = {}) { const requests = []; identifyItems.forEach(currItem => { - // At least search can call render without SelectedItem as Items, it just sends an object with the least possible fields render uses - // so we need to exclude those from related tables handling, as we know nothing about them - if (currItem instanceof SelectedItem && currItem.getLayer()) { - // Fire off a bunch of promises that fetches attachments and related tables - requests.push(addRelatedContent(currItem)); - } + requests.push(currItem.createContentAsync()); }); // Wait for all requests. If there are no attachments it just calls .then() without waiting. Promise.all(requests) @@ -580,7 +484,7 @@ const Featureinfo = function Featureinfo(options = {}) { doRender(identifyItems, target, coordinate, opts.ignorePan); }) .catch(err => console.log(err)); - }; + } /** * Shows the featureinfo popup/sidebar/infowindow for the provided features. Only vector layers are supported. @@ -602,7 +506,7 @@ const Featureinfo = function Featureinfo(options = {}) { newItems.push(newItem); }); }); - render(newItems, identifyTarget, opts.coordinate || maputils.getCenter(newItems[0].getFeature().getGeometry()), opts); + renderInternal(newItems, identifyTarget, opts.coordinate || maputils.getCenter(newItems[0].getFeature().getGeometry()), opts); }; /** @@ -634,7 +538,7 @@ const Featureinfo = function Featureinfo(options = {}) { newItems.push(newItem); } if (newItems.length > 0) { - render(newItems, identifyTarget, thisOpts.coordinate || maputils.getCenter(newItems[0].getFeature().getGeometry()), thisOpts); + renderInternal(newItems, identifyTarget, thisOpts.coordinate || maputils.getCenter(newItems[0].getFeature().getGeometry()), thisOpts); viewer.getMap().getView().fit(maputils.getExtent(newItems.map(i => i.getFeature())), { maxZoom: thisOpts.maxZoomLevel }); } }; @@ -664,7 +568,7 @@ const Featureinfo = function Featureinfo(options = {}) { const result = serverResult.concat(clientResult); if (result.length > 0) { selectionLayer.clear(false); - render(result, identifyTarget, evt.coordinate); + renderInternal(result, identifyTarget, evt.coordinate); } else if (selectionLayer.getFeatures().length > 0 || (identifyTarget === 'infowindow' && selectionManager.getNumberOfSelectedItems() > 0)) { clear(false); } else if (pinning) { diff --git a/src/getattributes.js b/src/getattributes.js index 99052cfc2..65625a5d7 100644 --- a/src/getattributes.js +++ b/src/getattributes.js @@ -2,6 +2,8 @@ import featureinfotemplates from './featureinfotemplates'; import replacer from './utils/replacer'; import isUrl from './utils/isurl'; import geom from './geom'; +import attachmentclient from './utils/attachmentclient'; +import relatedtables from './utils/relatedtables'; function createUrl(prefix, suffix, url) { const p = prefix || ''; @@ -185,12 +187,19 @@ function customAttribute(feature, attribute, attributes, map) { } return false; } - -function getAttributes(feature, layer, map) { +/** + * Internal helper that performs the actual content building + * @param {any} feature + * @param {any} layer + * @param {any} map + * @returns + */ +function getAttributesHelper(feature, layer, map) { const featureinfoElement = document.createElement('div'); featureinfoElement.classList.add('o-identify-content'); const ulList = document.createElement('ul'); featureinfoElement.appendChild(ulList); + // Create a shallow copy of properties present on the feature. Safe to manipulate keys but not values. const attributes = feature.getProperties(); const geometryName = feature.getGeometryName(); let attributeAlias = []; @@ -212,6 +221,7 @@ function getAttributes(feature, layer, map) { featureinfoElement.appendChild(templateList); templateList.innerHTML = li; } else { + // Assume layerAttributes is an array as it is not a string for (let i = 0; i < layerAttributes.length; i += 1) { attribute = layer.get('attributes')[i]; val = ''; @@ -253,5 +263,159 @@ function getAttributes(feature, layer, map) { return content; } +/** + * Creates the HTML visualization of the feature's attributes according to layer's attribute configuration. + * Does not include async content (related tables, attachments) + * @param {any} feature + * @param {any} layer + * @param {any} map + * @returns + */ +function getAttributes(feature, layer, map) { + // Add all hoisting attributes as actual properties on feature beacuse attributes configuration may try to use them + // Should only happen if user calls from api and has configured async content but are using sync function to create content + // Properties are removed when content is created because if we leave them they will exist permanently on the feature, + // which will create a crash when editing. + const attachments = layer.get('attachments'); + const attributesToRemove = []; + if (attachments) { + attachments.groups.forEach(a => { + if (a.linkAttribute) { + feature.set(a.linkAttribute, ''); + attributesToRemove.push(a.linkAttribute); + } + if (a.fileNameAttribute) { + feature.set(a.fileNameAttribute, ''); + attributesToRemove.push(a.fileNameAttribute); + } + }); + } + const relatedLayers = layer.get('relatedLayers'); + if (relatedLayers) { + relatedLayers.forEach(currLayer => { + if (currLayer.promoteAttribs) { + currLayer.promoteAttribs.forEach(currAttrib => { + feature.set(currAttrib.parentName, ''); + attributesToRemove.push(currAttrib.parentName); + }); + } + }); + } + const content = getAttributesHelper(feature, layer, map); + // remove the temporary attributes now that we're done with them + attributesToRemove.forEach(currAttrToRemove => feature.unset(currAttrToRemove, true)); + return content; +} + +/** + * Creates temporary attributes on a feature in order for featureinfo to display attributes from related tables and + * display attachments as links. Recursively adds attributes to related features in order to support multi level relations. + * In order to do so, attributes are also added to the related features. + * The hoistedAttributes array can be used to remove all attributes that have been added. + * @param {any} parentLayer The layer that holds the feature + * @param {any} parentFeature The feature to add attributes to + * @param {any} hoistedAttributes An existing array that is populated with the added attributes. + * @returns {boolean} True if layer has async content configured + */ +async function hoistRelatedAttributes(parentLayer, parentFeature, hoistedAttributes, layers) { + // This function is async and called recursively, DO NOT USE forEach!!! (It won't work) + + let dirty = false; + // Add attachments first but only if configured for attribute hoisting + const attachmentsConf = parentLayer.get('attachments'); + if (attachmentsConf && attachmentsConf.groups.some(g => g.linkAttribute || g.fileNameAttribute)) { + const ac = attachmentclient(parentLayer); + const attachments = await ac.getAttachments(parentFeature); + for (let i = 0; i < ac.getGroups().length; i += 1) { + const currAttrib = ac.getGroups()[i]; + let val = ''; + let texts = ''; + if (attachments.has(currAttrib.name)) { + const group = attachments.get(currAttrib.name); + val = group.map(g => g.url).join(';'); + texts = group.map(g => g.filename).join(';'); + } + if (currAttrib.linkAttribute) { + parentFeature.set(currAttrib.linkAttribute, val); + hoistedAttributes.push({ feature: parentFeature, attrib: currAttrib.linkAttribute }); + dirty = true; + } + if (currAttrib.fileNameAttribute) { + hoistedAttributes.push({ feature: parentFeature, attrib: currAttrib.fileNameAttribute }); + parentFeature.set(currAttrib.fileNameAttribute, texts); + dirty = true; + } + } + } + + // Add related layers + const relatedLayersConfig = relatedtables.getConfig(parentLayer); + if (relatedLayersConfig) { + for (let i = 0; i < relatedLayersConfig.length; i += 1) { + const layerConfig = relatedLayersConfig[i]; + + if (layerConfig.promoteAttribs) { + // First recurse our children so we can propagate from n-level to top level + // const childLayer = viewer.getLayer(layerConfig.layerName); + const childLayer = layers.find(l => l.get('name') === layerConfig.layerName); + // Function is recursice, we have to await + // eslint-disable-next-line no-await-in-loop + const childFeatures = await relatedtables.getChildFeatures(parentLayer, parentFeature, childLayer); + for (let jx = 0; jx < childFeatures.length; jx += 1) { + const childFeature = childFeatures[jx]; + // So here comes the infamous recursive call ... + // Function is recursice, we have to await + // eslint-disable-next-line no-await-in-loop + await hoistRelatedAttributes(childLayer, childFeature, hoistedAttributes); + } + + // Then actually hoist some related attributes + for (let j = 0; j < layerConfig.promoteAttribs.length; j += 1) { + const currAttribConf = layerConfig.promoteAttribs[j]; + const resarray = []; + childFeatures.forEach(child => { + // Collect the attributes from all children + // Here one could imagine supporting more attribute types, but html is pretty simple and powerful + if (currAttribConf.html) { + const val = replacer.replace(currAttribConf.html, child.getProperties()); + resarray.push(val); + } + }); + // Then actually aggregate them. Its a two step operation so in the future we could support more aggregate functions, like min(), max() etc + // and also to avoid appending manually and handle that pesky separator on last element. + const sep = currAttribConf.separator ? currAttribConf.separator : ''; + const resaggregate = resarray.join(sep); + parentFeature.set(currAttribConf.parentName, resaggregate); + hoistedAttributes.push({ feature: parentFeature, attrib: currAttribConf.parentName }); + dirty = true; + } + } + } + } + // Only returns true if top level is dirty. We don't build content for related objects. + return dirty; +} + +/** + * Creates the HTML visualization of the feature's attributes according to layer's attribute configuration. + * Includes async content (related tables, attachments) + * @param {any} feature + * @param {any} layer + * @param {any} map + */ +async function getAttributesAsync(feature, layer, map) { + const hoistedAttributes = []; + const layers = map.getLayers().getArray(); + // Add the temporary attributes with their values + await hoistRelatedAttributes(layer, feature, hoistedAttributes, layers); + const content = getAttributesHelper(feature, layer, map); + + // Remove all temporary added attributes. They mess up saving edits as there are no such fields in db. + hoistedAttributes.forEach(hoist => { + hoist.feature.unset(hoist.attrib, true); + }); + return content; +} + export default getAttributes; -export { getContent, featureinfotemplates }; +export { getContent, featureinfotemplates, getAttributesAsync }; diff --git a/src/getfeatureinfo.js b/src/getfeatureinfo.js index 503d533e1..677f7f765 100644 --- a/src/getfeatureinfo.js +++ b/src/getfeatureinfo.js @@ -5,6 +5,16 @@ import infoTemplates from './featureinfotemplates'; import maputils from './maputils'; import SelectedItem from './models/SelectedItem'; +/** + * Factory method to create a SelectedItem instance. Note that this method is exposed in api. + * Does not add async content (related tables and attachments). If you need async content use + * SelectItem.createContentAsync afterwards. + * @param {any} feature + * @param {any} layer + * @param {any} map + * @param {any} groupLayers + * @returns {SelectedItem} + */ function createSelectedItem(feature, layer, map, groupLayers) { // Above functions have no way of knowing whether the layer is part of a LayerGroup or not, therefore we need to check every layer against the groupLayers. const layerName = layer.get('name'); @@ -28,29 +38,6 @@ function createSelectedItem(feature, layer, map, groupLayers) { selectionGroupTitle = layer.get('title'); } - // Add pseudo attributes to make sure they exist when the SelectedItem is created as the content is created in constructor - // Ideally we would also populate here, but that is an async operation and will break the api. - const attachments = layer.get('attachments'); - if (attachments) { - attachments.groups.forEach(a => { - if (a.linkAttribute) { - feature.set(a.linkAttribute, ''); - } - if (a.fileNameAttribute) { - feature.set(a.fileNameAttribute, ''); - } - }); - } - const relatedLayers = layer.get('relatedLayers'); - if (relatedLayers) { - relatedLayers.forEach(currLayer => { - if (currLayer.promoteAttribs) { - currLayer.promoteAttribs.forEach(currAttrib => { - feature.set(currAttrib.parentName, ''); - }); - } - }); - } return new SelectedItem(feature, layer, map, selectionGroup, selectionGroupTitle); } diff --git a/src/models/SelectedItem.js b/src/models/SelectedItem.js index 12795e07f..b50347ad1 100644 --- a/src/models/SelectedItem.js +++ b/src/models/SelectedItem.js @@ -1,11 +1,25 @@ import { getUid } from 'ol'; -import getAttributes from '../getattributes'; - +import getAttributes, { getAttributesAsync } from '../getattributes'; +/** + * Class that represents a selected feature �n selection manager. Wraps a feature, its layer and html visualization of attributes + */ export default class SelectedItem { + /** + * Contructor for SelectedItem. Builds the content according to layer's configuration, but does not include async + * content (related tables, attachments) as that is an async operation. + * @param {any} feature + * @param {any} layer + * @param {any} map + * @param {any} selectionGroup + * @param {any} selectionGroupTitle + */ constructor(feature, layer, map, selectionGroup, selectionGroupTitle) { this.feature = feature; this.layer = layer; + this.map = map; if (layer && map) { + // Create the visual representation of this feature + // must not fail or be async as this is called from the contructor this.content = getAttributes(feature, layer, map); } @@ -13,6 +27,13 @@ export default class SelectedItem { this.selectionGroupTitle = selectionGroupTitle || layer.get('title'); } + /** + * Builds the content including async content. + */ + async createContentAsync() { + this.content = await getAttributesAsync(this.feature, this.layer, this.map); + } + getId() { let id = this.feature.getId() ? this.feature.getId().toString() : undefined; if (!id) {