From 547ff2221d908a68a8b9f239b469744277b8f92a Mon Sep 17 00:00:00 2001 From: Riccardo Beltrami Date: Fri, 15 Dec 2023 11:15:24 +0100 Subject: [PATCH] manage n-m relationships without the user interacting directly with the pivot tables --- assets/src/components/FeatureToolbar.js | 130 +- assets/src/legacy/attributeTable.js | 369 ++- assets/src/legacy/edition.js | 135 +- assets/src/legacy/map.js | 475 ++-- .../lizmap/classes/qgisVectorLayer.class.php | 19 +- .../lizmap/controllers/edition.classic.php | 479 +++- lizmap/modules/lizmap/lib/Form/QgisForm.php | 7 +- lizmap/modules/lizmap/lib/Project/Project.php | 12 + .../en_US/dictionnary.UTF-8.properties | 3 + .../locales/en_US/edition.UTF-8.properties | 4 + .../templates/edition_close_feature_data.tpl | 5 + lizmap/www/assets/css/map.css | 7 + phpstan-baseline.neon | 15 - .../playwright/n_to_m_relations.spec.ts | 431 +++ .../qgis-projects/tests/n_to_m_relations.qgs | 2420 +++++++++++++++++ .../tests/n_to_m_relations.qgs.cfg | 387 +++ tests/qgis-projects/tests/tests_dataset.sql | 99 + 17 files changed, 4629 insertions(+), 368 deletions(-) create mode 100644 tests/end2end/playwright/n_to_m_relations.spec.ts create mode 100644 tests/qgis-projects/tests/n_to_m_relations.qgs create mode 100644 tests/qgis-projects/tests/n_to_m_relations.qgs.cfg diff --git a/assets/src/components/FeatureToolbar.js b/assets/src/components/FeatureToolbar.js index 5850b679f0..48876d20b4 100644 --- a/assets/src/components/FeatureToolbar.js +++ b/assets/src/components/FeatureToolbar.js @@ -15,6 +15,8 @@ export default class FeatureToolbar extends HTMLElement { super(); [this._layerId, this._fid] = this.getAttribute('value').split('.'); + [this._pivotLayerId, this._parentFeatureId] = (this.getAttribute('pivot-layer') && this.getAttribute('pivot-layer').split(':') ) || [null, null]; + [this._pivotType, this._pivotLayerConfig] = this._pivotLayerId ? lizMap.getLayerConfigById(this._pivotLayerId) : [null, null]; [this._featureType, this._layerConfig] = lizMap.getLayerConfigById(this.layerId); this._typeName = this._layerConfig?.shortname || this._layerConfig?.typename || this._layerConfig?.name; this._parentLayerId = this.getAttribute('parent-layer-id'); @@ -36,8 +38,8 @@ export default class FeatureToolbar extends HTMLElement { - - + + ${this.isFeatureExportable @@ -159,6 +161,23 @@ export default class FeatureToolbar extends HTMLElement { return this._parentLayerId; } + get pivotLayerId(){ + const pivotAttributeLayerConf = lizMap.getLayerConfigById( this._pivotLayerId, lizMap.config.attributeLayers, 'layerId' ); + const config = lizMap.config; + if (pivotAttributeLayerConf + && pivotAttributeLayerConf[1]?.pivot == 'True' + && config.relations.pivot + && config.relations.pivot[this._pivotLayerId] + && config.relations.pivot[this._pivotLayerId][this.layerId] + && config.relations.pivot[this._pivotLayerId][this.parentLayerId] + ){ + return this._pivotLayerId; + } + + return null; + + } + get isSelected() { const selectedFeatures = this._layerConfig?.['selectedFeatures']; return selectedFeatures && selectedFeatures.includes(this.fid); @@ -187,19 +206,70 @@ export default class FeatureToolbar extends HTMLElement { return lizMap.getLayerConfigById(this.layerId, lizMap.config.attributeLayers, 'layerId')?.[1]; } + get pivotAttributeTableConfig(){ + return lizMap.getLayerConfigById(this.pivotLayerId, lizMap.config.attributeLayers,'layerId')?.[1]; + } + get isLayerEditable(){ return lizMap.config?.editionLayers?.[this.featureType]?.capabilities?.modifyAttribute === "True" || lizMap.config?.editionLayers?.[this.featureType]?.capabilities?.modifyGeometry === "True"; } - get isLayerPivot(){ - return this.attributeTableConfig?.['pivot'] === 'True'; + get isNToMRelation() { + if (this.pivotLayerId) return true; + else return false; } get isUnlinkable(){ return this.parentLayerId && - (this.isLayerEditable && !this.isLayerPivot) || - (lizMap.config?.editionLayers?.[this.featureType]?.capabilities?.deleteFeature === "True" && this.isLayerPivot); + (this.isLayerEditable && !this.isNToMRelation) || + (lizMap.config?.editionLayers?.[this._pivotType]?.capabilities?.deleteFeature === "True" && this.isNToMRelation); + } + + get pivotFeatureId(){ + const pivotLayerId = this.pivotLayerId; + + if(!pivotLayerId) return null; + + const parentLayerId = this.parentLayerId; + const config = lizMap.config; + + // parent and current layer should be configured in relations object + if (!(parentLayerId in config.relations) || !(this.layerId in config.relations) || !this._parentFeatureId){ + return null; + } + + // pivot contains features? + const features = config.layers[this._pivotType]['features']; + if (!features || Object.keys(features).length <= 0){ + return null; + } + // get pivot primary key + const primaryKey = this.pivotAttributeTableConfig?.['primaryKey']; + if(!primaryKey){ + return null; + } + + //get referencing field for the pivot + const layerReferencingField = config.relations[this.layerId].filter((rel)=>{ + return rel.referencingLayer == pivotLayerId && rel.referencingField == config.relations.pivot[pivotLayerId][this.layerId] + })?.[0]?.referencingField; + + const parentLayerReferencingField = config.relations[this.parentLayerId].filter((rel)=>{ + return rel.referencingLayer == pivotLayerId && rel.referencingField == config.relations.pivot[pivotLayerId][this.parentLayerId] + })?.[0]?.referencingField; + + if (!layerReferencingField || !parentLayerReferencingField) return null; + + // get features from pivot corresponding to the current layer + const pivotFeature = Object.keys(features).filter((feat) =>{ + const properties = features[feat].properties; + return properties && properties[layerReferencingField] && properties[layerReferencingField] == this.fid && properties[parentLayerReferencingField] && properties[parentLayerReferencingField] == this._parentFeatureId + }) + + if (pivotFeature.length == 1 && features[pivotFeature[0]].properties[primaryKey]) { + return features[pivotFeature[0]].properties[primaryKey] + } else return null } /** @@ -209,8 +279,8 @@ export default class FeatureToolbar extends HTMLElement { */ get isDeletable(){ return this._isFeatureEditable - && lizMap.config?.editionLayers?.[this.featureType]?.capabilities?.deleteFeature === "True" - && !this.isLayerPivot; + && ((lizMap.config?.editionLayers?.[this.featureType]?.capabilities?.deleteFeature === "True" + && !this.isNToMRelation) || (this.isNToMRelation && lizMap.config?.editionLayers?.[this.featureType]?.capabilities?.deleteFeature === "True" && lizMap.config?.editionLayers?.[this._pivotType]?.capabilities?.deleteFeature === "True")); } get hasEditionRestricted(){ @@ -280,7 +350,8 @@ export default class FeatureToolbar extends HTMLElement { // Check if the child layer has insert capabilities let [childFeatureType, childLayerConfig] = lizMap.getLayerConfigById(relation.referencingLayer); - if (lizMap.config?.editionLayers?.[childFeatureType]?.capabilities?.createFeature !== "True") { + let isPivot = !!lizMap.config?.relations?.pivot?.[relation.referencingLayer] + if (isPivot || lizMap.config?.editionLayers?.[childFeatureType]?.capabilities?.createFeature !== "True") { return; } editableChildrenLayers.push({ @@ -367,7 +438,46 @@ export default class FeatureToolbar extends HTMLElement { } delete(){ - lizMap.deleteEditionFeature(this.layerId, this.fid); + // get list of tables that are linked to the pivot + let relations = lizMap.config?.relations?.[this.layerId], message = ""; + if(relations && lizMap.config?.relations?.pivot){ + + let pivotNames = relations.map((relation)=>{ + return relation.referencingLayer + }).filter((refLayer)=>{ + const attributeTableConf = lizMap.getLayerConfigById(refLayer, lizMap.config.attributeLayers,'layerId') + return attributeTableConf && attributeTableConf[1]?.pivot == 'True' && refLayer && refLayer in lizMap.config.relations.pivot && Object.keys(lizMap.config.relations.pivot[refLayer]).some((kp)=>{return kp == this.layerId}) + }).map((key)=>{ + let relatedLayerId = Object.keys(lizMap.config.relations.pivot[key]).filter((k)=> { return k !== this.layerId})?.[0] + if (relatedLayerId) { + return lizMap.getLayerConfigById(relatedLayerId)?.[1]?.title || lizMap.getLayerConfigById(relatedLayerId)?.[1]?.name + } + else return ""; + }).reduce((acc,current)=> acc+"\n" +current,"") + + if (pivotNames) { + message = lizDict['edition.confirm.pivot.delete'].replace('%s',pivotNames); + } + } + + lizMap.deleteEditionFeature(this.layerId, this.fid, message); + } + + deleteFromPivot(){ + let pivotFeatureId = this.pivotFeatureId; + if( pivotFeatureId ){ + let unlinkMessage = lizDict['edition.confirm.pivot.unlink'].replace("%l", lizMap.getLayerConfigById(this.parentLayerId)[1].title) + lizMap.deleteEditionFeature(this.pivotLayerId, pivotFeatureId, unlinkMessage, ()=>{ + // refresh mlayer + lizMap.events.triggerEvent("lizmapeditionfeaturedeleted", + { + 'layerId': this.layerId, + 'featureId': this.fid, + 'featureType': this.featureType, + 'updateDrawing': true + }); + }); + } } unlink(){ diff --git a/assets/src/legacy/attributeTable.js b/assets/src/legacy/attributeTable.js index 21af8de8d7..0a99fa04f3 100644 --- a/assets/src/legacy/attributeTable.js +++ b/assets/src/legacy/attributeTable.js @@ -183,7 +183,7 @@ var lizAttributeTable = function() { const tableSelector = '#attribute-layer-table-' + cleanName; // Get data and fill attribute table - getDataAndFillAttributeTable(layerName, layerFilter, tableSelector); + getDataAndFillAttributeTable(layerName, layerFilter, tableSelector, false); $('#nav-tab-attribute-layer-' + cleanName + ' a' ).tab('show'); @@ -279,9 +279,10 @@ var lizAttributeTable = function() { * @param layerName * @param filter * @param tableSelector + * @param forceEmptyTable * @param callBack */ - function getDataAndFillAttributeTable(layerName, filter, tableSelector, callBack){ + function getDataAndFillAttributeTable(layerName, filter, tableSelector, forceEmptyTable, callBack){ let layerConfig = lizMap.config.layers[layerName]; const typeName = layerConfig.typename; @@ -344,6 +345,7 @@ var lizAttributeTable = function() { })); } } + if (forceEmptyTable) return buildLayerAttributeDatatable(layerName, tableSelector, [], layerConfig.aliases, layerConfig.types, allColumnsKeyValues, callBack); document.body.style.cursor = 'progress'; Promise.all(fetchRequests).then(responses => { @@ -677,7 +679,7 @@ var lizAttributeTable = function() { const tableSelector = '#attribute-layer-table-'+cleanName; $('#attribute-layer-main-'+cleanName+' > div.attribute-layer-content').hide(); - getDataAndFillAttributeTable(lname, null, tableSelector, () => { + getDataAndFillAttributeTable(lname, null, tableSelector, false, () => { $('#attribute-layer-main-' + cleanName + ' > div.attribute-layer-content').show(); refreshDatatableSize('#attribute-layer-main-' + cleanName); }); @@ -801,9 +803,10 @@ var lizAttributeTable = function() { var parentLayerName = attributeLayersDic[ cleanName ]; var parentLayerId = config.layers[parentLayerName]['id']; var aName = attributeLayersDic[ $(this).val() ]; + var pivotId = $(this).attr("data-pivot"); lizMap.getLayerFeature(parentLayerName, parentFeatId, function(parentFeat) { var lid = config.layers[aName]['id']; - lizMap.launchEdition( lid, null, {layerId:parentLayerId,feature:parentFeat}); + lizMap.launchEdition( lid, null, {layerId:parentLayerId, feature:parentFeat, pivotId:pivotId}); }); return false; }) @@ -923,7 +926,19 @@ var lizAttributeTable = function() { lizMap.events.triggerEvent("layerfeatureunselectall", { 'featureType': attributeLayersDic[cleanName], 'updateDrawing': true} ); - // Send signal saying edition has been done on pivot + // Send signal saying edition has been done on pivot and refresh corresponding tables + var linkedId = lizMap.config.layers[attributeLayersDic[cleanName]]?.id + if (linkedId) { + var pivotCfg = lizMap.config.relations.pivot[cId]; + // get layerId of related layer + var linkedKey = Object.keys(pivotCfg).filter((key)=>{ + return key != linkedId + })?.[0] + + if (linkedKey) { + cId = linkedKey; + } + } lizMap.events.triggerEvent("lizmapeditionfeaturecreated", { 'layerId': cId} ); @@ -1024,19 +1039,30 @@ var lizAttributeTable = function() { var childActive = 'active'; for( var lid in layerRelations ) { var relation = layerRelations[lid]; - var childLayerConfigA = lizMap.getLayerConfigById( + var referencingLayerConfig = lizMap.getLayerConfigById( relation.referencingLayer, config.layers, 'id' ); - if( childLayerConfigA - && childLayerConfigA[0] in config.attributeLayers - ){ + var isNToM = false; + var pivotConfig = null; + var mLayerConfig = null; + + if (referencingLayerConfig && referencingLayerConfig[0] in config.attributeLayers) { + // check if the renferencing layer is a pivot + mLayerConfig = getPivotLinkedLayerConfiguration(parentLayerId, referencingLayerConfig[1]); + // if so, switch the child layer to the "n_layer" if the n_layer could be displayed in attribute table + if( mLayerConfig && mLayerConfig.config && mLayerConfig.config[0] in config.attributeLayers) { + // store original pivot configuration + pivotConfig = referencingLayerConfig; + isNToM = true; + } childCount+=1; if( childCount > 1) childActive = ''; - var childLayerConfig = childLayerConfigA[1]; - var childLayerName = childLayerConfigA[0]; + // if the detected relation is n to m, then use mLayer configuration to display the child attribute table + var childLayerConfig = isNToM ? mLayerConfig.config[1] : referencingLayerConfig[1]; + var childLayerName = isNToM ? mLayerConfig.config[0] : referencingLayerConfig[0]; var childAttributeLayerConfig = config.attributeLayers[childLayerName]; // Discard if the editor does not want this layer to be displayed in child table @@ -1063,29 +1089,28 @@ var lizAttributeTable = function() { // Add create child feature button var canCreateChild = false; if( 'editionLayers' in config ){ - var editionConfig = lizMap.getLayerConfigById( - relation.referencingLayer, - config.editionLayers, - 'layerId' - ); if( childLayerName in config.editionLayers ) { var al = config.editionLayers[childLayerName]; if( al.capabilities.createFeature == "True" ) canCreateChild = true; } + // if the m layer is displayed then check also the edition capabilities on pivot + if(canCreateChild && isNToM){ + // check edition capabilities for pivot table + canCreateChild = pivotConfig[0] in config.editionLayers && config.editionLayers[pivotConfig[0]] && config.editionLayers[pivotConfig[0]].capabilities.createFeature == 'True' + } } - if( canCreateChild ){ // Add a button to create a new feature for this child layer let childButtonItem = ` - `; childCreateButtonItems.push(childButtonItem); // Link parent with the selected features of the child - layerLinkButtonItems.push('
  • ' + childLayerConfig.title +'
  • ' ); + layerLinkButtonItems.push('
  • ' + (isNToM ? pivotConfig[1].title : childLayerConfig.title) +'
  • ' ); } } } @@ -1147,28 +1172,41 @@ var lizAttributeTable = function() { if( 'relations' in config && parentLayerId in config.relations) { var layerRelations = config.relations[parentLayerId]; for (const relation of layerRelations ) { - const childLayerConfigA = lizMap.getLayerConfigById( + var referencingLayerConfig = lizMap.getLayerConfigById( relation.referencingLayer, config.layers, 'id' ); // Fill in attribute table for child - // Discard if the editor does not want this layer to be displayed in child table - if( childLayerConfigA - && config.attributeLayers?.[childLayerConfigA[0]]?.['hideAsChild'] == 'False' - ){ - const [childLayerName, childLayerConfig] = childLayerConfigA; - // Generate filter - let filter = ''; - if( relation.referencingLayer == childLayerConfig.id ){ - filter = '"' + relation.referencingField + '" = ' + "'" + fp[relation.referencedField] + "'"; + if( referencingLayerConfig ) { + var isNToM = false, mLayerConfig = null; + // check if the referencingLayer is a pivot table + mLayerConfig = getPivotLinkedLayerConfiguration(parentLayerId, referencingLayerConfig[1]); + if (mLayerConfig) { + // if the realtion is n to m, switch the layer config to the mLayer + referencingLayerConfig = mLayerConfig.config; + isNToM = true; + } + // Discard if the editor does not want this layer to be displayed in child table + if (config.attributeLayers?.[referencingLayerConfig[0]]?.['hideAsChild'] == 'False') { + const [childLayerName, childLayerConfig] = referencingLayerConfig; + // Get child table id + const childTableSelector = sourceTable.replace(' table:first', '') + '-' + lizMap.cleanName(childLayerName); + // Generate filter + let filter = ''; + if ( isNToM ) { + // get feature from pivot + getPivotWFSFeatures(relation.referencingLayer, mLayerConfig.relation, fp[relation.referencedField]).then((filterString)=>{ + getEditionChildData(childLayerName, filterString, childTableSelector, filterString ? false : true); + }) + } else { + if( relation.referencingLayer == childLayerConfig.id ){ + filter = '"' + relation.referencingField + '" = ' + "'" + fp[relation.referencedField] + "'"; + } + getDataAndFillAttributeTable(childLayerName, filter, childTableSelector, false); + } } - - // Get child table id - const childTableSelector = sourceTable.replace(' table:first', '') + '-' + lizMap.cleanName(childLayerName); - - getDataAndFillAttributeTable(childLayerName, filter, childTableSelector); } } } @@ -1327,7 +1365,28 @@ var lizAttributeTable = function() { ){ isPivot = true; } - + var pivotReference = null; + // checks if the parent and child are related via pivot + if (parentLayerID) { + // means that the table is displayed as a child + var parentLayerConfig = lizMap.getLayerConfigById(parentLayerID); + var fromEditionForm = aTable.startsWith('#attribute-layer-table-') ? false : (aTable.startsWith('#edition-table-') ? true : false); + var highlightedFeature = null; + if (fromEditionForm) { + // get fid of current layer on editing + highlightedFeature = $('#edition-form-container form input[name="liz_featureId"]').val() + + } else highlightedFeature = config.layers[parentLayerConfig[1].cleanname].highlightedFeature; + + if (parentLayerConfig && parentLayerConfig[1] && parentLayerConfig[1].cleanname && highlightedFeature) { + + var childLayerId = lConfig.id; + var pivotId = getPivotIdFromRelatedLayers(parentLayerID, childLayerId); + if (pivotId) { + pivotReference = pivotId + ":" + highlightedFeature; + } + } + } // Hidden fields var hiddenFields = []; if( aName in config.attributeLayers @@ -1375,8 +1434,8 @@ var lizAttributeTable = function() { hiddenFields, lConfig['selectedFeatures'], lConfig['id'], - parentLayerID - ); + parentLayerID, + pivotReference); var foundFeatures = ff.foundFeatures; var dataSet = ff.dataSet; @@ -1516,6 +1575,7 @@ var lizAttributeTable = function() { } else { $(aTable).show(); + refreshDatatableSize('#'+$('#bottom-dock div.bottom-content.active div.attribute-layer-main').attr('id')) } @@ -1735,8 +1795,9 @@ var lizAttributeTable = function() { * @param selectedFeatures * @param layerId * @param parentLayerID + * @param pivotId */ - function formatDatatableFeatures(atFeatures, isChild, hiddenFields, selectedFeatures, layerId, parentLayerID){ + function formatDatatableFeatures(atFeatures, isChild, hiddenFields, selectedFeatures, layerId, parentLayerID, pivotId = null){ var dataSet = []; var foundFeatures = {}; atFeatures.forEach(function(feat) { @@ -1752,8 +1813,7 @@ var lizAttributeTable = function() { if( selectedFeatures && $.inArray( fid, selectedFeatures ) != -1 ) line.lizSelected = 'a'; - - line['featureToolbar'] = ``; + line['featureToolbar'] = ``; // Build table lines for (var idx in feat.properties){ @@ -1831,9 +1891,10 @@ var lizAttributeTable = function() { * @param childLayerName * @param filter * @param childTable + * @param forceEmptyTable */ - function getEditionChildData( childLayerName, filter, childTable ){ - getDataAndFillAttributeTable(childLayerName, filter, childTable, () => { + function getEditionChildData( childLayerName, filter, childTable, forceEmptyTable = false ){ + getDataAndFillAttributeTable(childLayerName, filter, childTable, forceEmptyTable, () => { // Check edition capabilities var canCreate = false; var canEdit = false; @@ -1872,7 +1933,10 @@ var lizAttributeTable = function() { lizMap.getLayerFeature(parentLayerName, parentFeatId, function (parentFeat) { var parentLayerId = config.layers[lizMap.getLayerNameByCleanName(parentLayerName)]['id']; var lid = config.layers[lizMap.getLayerNameByCleanName(layerName)]['id']; - lizMap.launchEdition(lid, null, { layerId: parentLayerId, feature: parentFeat }); + // n to m relations check + var pivotId = getPivotIdFromRelatedLayers(lid, parentLayerId); + + lizMap.launchEdition(lid, null, { layerId: parentLayerId, feature: parentFeat, pivotId: pivotId }); }); return false; }); @@ -2942,13 +3006,51 @@ var lizAttributeTable = function() { if ( (featureType in config.attributeLayers) && parentLayerName == getParentLayerConfig[0] ) { // get featureType layer config var featureTypeConfig = config.attributeLayers[featureType]; + + // n to m checks + var pivotId = getPivotIdFromRelatedLayers(formLayerId, featureTypeConfig.layerId) + //get relation var relation = getRelationInfo(formLayerId,featureTypeConfig.layerId); - if( relation != null ) { + + if( relation != null || pivotId) { lizMap.getLayerFeature(parentLayerName, formFeatureId, function(feat) { var fp = feat.properties; - filter = '"' + relation.referencingField + '" = ' + "'" + fp[relation.referencedField] + "'"; - getEditionChildData( featureType, filter, zTable); + if (pivotId) { + const currentPivot = config.relations.pivot[pivotId]; + const pivotConfig = lizMap.getLayerConfigById( + pivotId, + config.layers, + 'id' + ); + if( pivotConfig && pivotConfig[1] ){ + + const referencedPivotField = currentPivot[formLayerId]; + const referencingPivotField = currentPivot[featureTypeConfig.layerId]; + const referencedFieldForFilter = config.relations[featureTypeConfig.layerId].filter((fil)=>{ + return fil.referencingLayer == pivotId + })[0]?.referencedField; + + const childReferencedField = config.relations[featureTypeConfig.layerId].filter((rel)=>{ + return rel.referencingLayer == pivotId + })[0]?.referencedField; + + if(referencedPivotField && referencingPivotField && referencedFieldForFilter && childReferencedField){ + const pWfsParam = { + referencedPivotField : referencedPivotField, + referencingPivotField : referencingPivotField, + referencedFieldForFilter : referencedFieldForFilter + } + getPivotWFSFeatures(pivotId, pWfsParam, fp[childReferencedField]).then((filterString)=>{ + getEditionChildData(featureType, filterString, zTable, filterString ? false : true); + }) + } + } + } else { + var filter = '"' + relation.referencingField + '" = ' + "'" + fp[relation.referencedField] + "'"; + getEditionChildData( featureType, filter, zTable); + } + }); } } @@ -2961,7 +3063,7 @@ var lizAttributeTable = function() { // Else refresh main table with no filter else{ // If not pivot - getDataAndFillAttributeTable(featureType, null, zTable); + getDataAndFillAttributeTable(featureType, null, zTable, false); } } }); @@ -2992,6 +3094,144 @@ var lizAttributeTable = function() { dtable.DataTable().tables().columns.adjust(); } + /** + * + * @param nlayerId + * @param referencingLayerConfig + */ + function getPivotLinkedLayerConfiguration(nlayerId, referencingLayerConfig){ + const refAttributeLayerConf = lizMap.getLayerConfigById( referencingLayerConfig.id, lizMap.config.attributeLayers, 'layerId' ); + + if (refAttributeLayerConf && refAttributeLayerConf[1]?.pivot == 'True' && config.relations?.pivot && referencingLayerConfig.id in config.relations.pivot && nlayerId in config.relations.pivot[referencingLayerConfig.id]){ + // get referenced layer for the parent layer + const referencedLayer = Object.keys(config.relations.pivot[referencingLayerConfig.id]).filter((k)=>{ return k != nlayerId}) + var mLayerConfig = null; + if (referencedLayer.length == 1) { + mLayerConfig = lizMap.getLayerConfigById( + referencedLayer[0], + config.layers, + 'id' + ); + + if (mLayerConfig) { + var currentPivot = config.relations.pivot[referencingLayerConfig.id]; + if (currentPivot) { + const referencedPivotField = currentPivot[nlayerId]; + const referencingPivotField = currentPivot[referencedLayer[0]]; + const referencedFieldForFilter = config.relations[mLayerConfig[1].id].filter((fil)=>{ + return fil.referencingLayer == referencingLayerConfig.id + })[0]?.referencedField; + + if (referencedPivotField && referencingPivotField && referencedFieldForFilter) { + return { + config:mLayerConfig, + relation:{ + referencedPivotField:referencedPivotField, + referencingPivotField:referencingPivotField, + referencedFieldForFilter:referencedFieldForFilter + } + } + } + } + } + } + } + return null; + } + + /** + * + * @param nlayerId + * @param mLayerId + */ + function getPivotIdFromRelatedLayers(nlayerId, mLayerId){ + // returns the pivotId starting from the related layers + // this method assumes that the mLayer and nLayer is related with n to m relation via a single pivot + // i.e. there is not n to m relation duplication + if (config.relations.pivot) { + var pivotId = Object.keys(config.relations.pivot).filter((key)=>{ + return config.relations.pivot[key][mLayerId] != null && config.relations.pivot[key][nlayerId] != null; + })[0] //<--- assumes that the couple father-childs belongs to a single pivot + + if (pivotId) { + const refAttributeLayerConf = lizMap.getLayerConfigById( pivotId, lizMap.config.attributeLayers, 'layerId' ); + if(refAttributeLayerConf && refAttributeLayerConf[1]?.pivot == 'True'){ + // check if pivot is in relations for both layers + const validRelation = [nlayerId,mLayerId].every((layerId)=>{ + return config.relations[layerId] && config.relations[layerId].filter((rlayer)=>{ return rlayer.referencingLayer == pivotId}).length == 1 + }) + if (validRelation) + return pivotId; + } + } + } + return null; + } + + /** + * + * @param pivotId + * @param wfsFields + * @param referencedFieldValue + */ + async function getPivotWFSFeatures(pivotId, wfsFields, referencedFieldValue){ + + const pivotConfig = lizMap.getLayerConfigById( + pivotId, + config.layers, + 'id' + ); + + let filterString = "", feats = {}; + if( pivotConfig && pivotConfig[1] ){ + // the field on the pivot linked to the nLayer + const referencedPivotField = wfsFields.referencedPivotField; + // the field on the pivot linked to the mLayer + const referencingPivotField = wfsFields.referencingPivotField; + // the field on the mLayer linked the the pivot + const referencedFieldForFilter = wfsFields.referencedFieldForFilter; + + const typeName = pivotConfig[1].typename; + const wfsParams = { + TYPENAME: typeName, + GEOMETRYNAME: 'extent' + }; + wfsParams['EXP_FILTER'] = '"' + referencedPivotField + '" = ' + "'" + referencedFieldValue + "'"; + if (config.options?.limitDataToBbox == 'True') { + wfsParams['BBOX'] = lizMap.mainLizmap.map.getView().calculateExtent(); + wfsParams['SRSNAME'] = lizMap.mainLizmap.map.getView().getProjection().getCode(); + } + + const getFeatureRequest = lizMap.mainLizmap.wfs.getFeature(wfsParams); + + let results = await getFeatureRequest; + + if(results && results.features){ + + const features = results.features; + + let filArray = []; + features.forEach((feat)=>{ + var fid = feat.id.split('.')[1]; + feats[fid] = feat; + if (feat.properties && feat.properties[referencingPivotField]) { + filArray.push(feat.properties[referencingPivotField]) + } + }) + if (filArray.length) { + var fil = filArray.map(function(val){ + return '"'+referencedFieldForFilter+'" = \''+val+'\''; + }) + filterString = fil.join(" OR "); + } + } + + } + pivotConfig[1].features = feats; + + return filterString; + } + lizMap.refreshDatatableSize = function(container){ return refreshDatatableSize(container); } @@ -3223,9 +3463,10 @@ var lizAttributeTable = function() { } var parentLayerId = layerId; var aName = attributeLayersDic[ $(this).val() ]; + var pivotId = $(this).attr("data-pivot"); lizMap.getLayerFeature(featureType, fid, function(parentFeat) { var lid = config.layers[aName]['id']; - lizMap.launchEdition( lid, null, {layerId:parentLayerId, feature:parentFeat}); + lizMap.launchEdition( lid, null, {layerId:parentLayerId, feature:parentFeat, pivotId: pivotId}); }); return false; }) @@ -3260,16 +3501,32 @@ var lizAttributeTable = function() { var rLayerId = r.referencingLayer; var rGetLayerConfig = lizMap.getLayerConfigById( rLayerId ); if ( rGetLayerConfig ) { - var rLayerName = rGetLayerConfig[0]; - var rConfigLayer = rGetLayerConfig[1]; - var filter = '"' + r.referencingField + '" = ' + "'" + fp[r.referencedField] + "'"; - // Get child table id - var parent_and_child = lizMap.cleanName(featureType) + '-' + lizMap.cleanName(rLayerName); - var childTable = '#edition-table-' + parent_and_child; + // check if relation is nToM + var isNToM = false, mLayerConfig = null; + // check if the referencingLayer is a pivot table + mLayerConfig = getPivotLinkedLayerConfiguration(layerId, rGetLayerConfig[1]); + if (mLayerConfig) { + // if the realtion is n to m, switch the layer config to the mLayer + rGetLayerConfig = mLayerConfig.config; + isNToM = true; + } + let rLayerName = rGetLayerConfig[0]; + let rConfigLayer = rGetLayerConfig[1]; + let filter = ""; + // Get child table id + let parent_and_child = lizMap.cleanName(featureType) + '-' + lizMap.cleanName(rLayerName); + let childTable = '#edition-table-' + parent_and_child; // Fill in attribute table for child - if( rLayerName in config.attributeLayers ) { - getEditionChildData( rLayerName, filter, childTable ); + if(rLayerName in config.attributeLayers){ + if (isNToM) { + getPivotWFSFeatures(r.referencingLayer, mLayerConfig.relation, fp[r.referencedField]).then((filterString)=>{ + getEditionChildData(rLayerName, filterString, childTable, filterString ? false : true); + }) + } else { + filter = '"' + r.referencingField + '" = ' + "'" + fp[r.referencedField] + "'"; + getEditionChildData( rLayerName, filter, childTable ); + } } // Try to move the tables inside the parent form diff --git a/assets/src/legacy/edition.js b/assets/src/legacy/edition.js index e86e4a1a6b..8386b0d00d 100644 --- a/assets/src/legacy/edition.js +++ b/assets/src/legacy/edition.js @@ -35,6 +35,8 @@ var lizEdition = function() { this.backToParent = false; /** @member {[Feature, FormData][]} new features to save on submit (features created after a split) */ this.newfeatures = []; + /**@member {string} pivot tell if the parent is in n to m relation with the child*/ + this.pivot = null; } FeatureEditionData.prototype = { setParentToEditAfterSave: function (parent) { @@ -602,14 +604,38 @@ var lizEdition = function() { * * @param parentLayerId * @param childLayerId + * @param pivotId */ - function getRelationInfo(parentLayerId,childLayerId){ + function getRelationInfo(parentLayerId, childLayerId, pivotId){ if( 'relations' in config && parentLayerId in config.relations) { - var layerRelations = config.relations[parentLayerId]; - for( var lridx in layerRelations ) { - var relation = layerRelations[lridx]; - if (relation.referencingLayer == childLayerId) { - return relation; + if (pivotId) { + const pivotAttributeLayerConf = lizMap.getLayerConfigById( pivotId, lizMap.config.attributeLayers, 'layerId' ); + if(pivotAttributeLayerConf && pivotAttributeLayerConf[1]?.pivot == 'True'){ + var pivot = config.relations.pivot[pivotId] + if (pivot) { + // validate pivot reference + var validRelation = true; + Object.keys(pivot).forEach((k)=>{ + if(validRelation){ + if(k == parentLayerId || k == childLayerId){ + var pvRelation = config.relations[k] || []; + var pr = pvRelation.filter((rel)=>{ + return rel.referencingLayer == pivotId; + }) + if(pr.length == 0) validRelation = false; + } else validRelation = false; + } + }) + if(validRelation) return pivot; + } + } + } else { + var layerRelations = config.relations[parentLayerId]; + for( var lridx in layerRelations ) { + var relation = layerRelations[lridx]; + if (relation.referencingLayer == childLayerId) { + return relation; + } } } } @@ -1249,11 +1275,12 @@ var lizEdition = function() { if (aParent != null && ('layerId' in aParent) && ('feature' in aParent)) { var parentLayerId = aParent['layerId']; var parentFeat = aParent['feature']; + var pivotId = aParent['pivotId'] if ('relations' in config && parentLayerId in config.relations) { - var relation = getRelationInfo(parentLayerId, aLayerId); - if (relation != null && - relation.referencingLayer == aLayerId + var relation = getRelationInfo(parentLayerId, aLayerId, pivotId); + if (relation != null && (pivotId || + relation.referencingLayer == aLayerId) ) { // the given parent information corresponds to a real parent // of the feature we want to edit, we take care about it @@ -1267,6 +1294,7 @@ var lizEdition = function() { parentInfo = editionLayer.currentFeature; parentInfo.relation = relation; parentInfo.feature = parentFeat; + if (pivotId) parentInfo.pivot = pivotId; editedFeature.setParentToEditAfterSave(parentInfo); // and clear edition context finishEdition(); @@ -1276,6 +1304,7 @@ var lizEdition = function() { if (!parentInfo) { // let's store parent data into a FeatureEditionData parentInfo = new FeatureEditionData(parentLayerId, parentFeat, relation); + if (pivotId) parentInfo.pivot = pivotId; editedFeature.parent = parentInfo; } } @@ -1531,42 +1560,65 @@ var lizEdition = function() { var relation = parentInfo['relation']; var relationRefField = relation.referencingField; var parentFeatProp = parentInfo['feature'].properties[relation.referencedField]; + var pivot = parentInfo.pivot; + // link feature only when the user is creating a new feature (avoid duplicate associations on pivot) + if (pivot && editionType == 'createFeature') { + // get referencing field + var referencedField = config.relations[parentInfo.layerId].filter((rel) => { + return rel.referencingLayer == pivot; + })[0]?.referencedField + if (referencedField){ + var parentLayerConf = lizMap.getLayerConfigById(parentInfo.layerId); + if(parentLayerConf[1]){ + // add hidden input for manage feature link + var hiddenInput = $('') + .attr('id', pivot+'_hidden') + .attr('name', "liz_pivot") + .attr('value', pivot+":"+parentInfo.layerId+":"+parentInfo['feature'].properties[referencedField]); + form.find('div.jforms-hiddens').append(hiddenInput); + var futureLinkInfo = '
    '; + form.find(".control-group").last().append(futureLinkInfo); + } - var select = form.find('select[name="'+relationRefField+'"]'); - if( select.length == 1 ){ - // Disable the select, the value will be stored in an hidden input - select.val(parentFeatProp) - .attr('disabled','disabled'); - // Create hidden input to store value because the select is disabled - var hiddenInput = $('') - .attr('id', select.attr('id')+'_hidden') - .attr('name', relationRefField) - .attr('value', parentFeatProp); - form.find('div.jforms-hiddens').append(hiddenInput); - // Disable required constraint - jFormsJQ.getForm(form.attr('id')) - .getControl(relationRefField) - .required=false; + } } else { - var input = form.find('input[name="'+relationRefField+'"]'); - if( input.length == 1 && input.attr('type') != 'hidden'){ + var select = form.find('select[name="'+relationRefField+'"]'); + if( select.length == 1 ){ // Disable the select, the value will be stored in an hidden input - input.val(parentFeatProp) + select.val(parentFeatProp) .attr('disabled','disabled'); // Create hidden input to store value because the select is disabled var hiddenInput = $('') - .attr('id', input.attr('id')+'_hidden') + .attr('id', select.attr('id')+'_hidden') .attr('name', relationRefField) .attr('value', parentFeatProp); form.find('div.jforms-hiddens').append(hiddenInput); // Disable required constraint - jFormsJQ.getForm($('#edition-form-container form').attr('id')) + jFormsJQ.getForm(form.attr('id')) .getControl(relationRefField) .required=false; + } else { + var input = form.find('input[name="'+relationRefField+'"]'); + if( input.length == 1 && input.attr('type') != 'hidden'){ + // Disable the select, the value will be stored in an hidden input + input.val(parentFeatProp) + .attr('disabled','disabled'); + // Create hidden input to store value because the select is disabled + var hiddenInput = $('') + .attr('id', input.attr('id')+'_hidden') + .attr('name', relationRefField) + .attr('value', parentFeatProp); + form.find('div.jforms-hiddens').append(hiddenInput); + // Disable required constraint + jFormsJQ.getForm($('#edition-form-container form').attr('id')) + .getControl(relationRefField) + .required=false; + } + else + input.val(parentFeatProp); } - else - input.val(parentFeatProp); } + } // Create combobox based on RelationValue with fieldEditable @@ -2173,9 +2225,23 @@ var lizEdition = function() { if ( 'shortname' in configLayer && configLayer.shortname != '' ) typeName = configLayer.shortname; + // check if the layer has n to m relations + var hasNToMRelations = false, isPivot = !!lizMap.config?.relations?.pivot?.[aLayerId]; + if(lizMap.config?.relations?.[aLayerId]){ + hasNToMRelations = lizMap.config.relations[aLayerId].some((el)=>{ + const pivotAttributeLayerConf = lizMap.getLayerConfigById( el.referencingLayer, lizMap.config.attributeLayers, 'layerId' ); + return lizMap.config.relations.pivot && lizMap.config.relations.pivot[el.referencingLayer] != null && pivotAttributeLayerConf[1]?.pivot == 'True' + }) + } var deleteConfirm = lizDict['edition.confirm.delete']; - if ( aMessage ) - deleteConfirm += '\n' + aMessage; + + if(aMessage){ + if(hasNToMRelations || isPivot){ + deleteConfirm = aMessage; + } else { + deleteConfirm += '\n' + aMessage; + } + } if ( !confirm( deleteConfirm ) ) return false; @@ -2183,7 +2249,8 @@ var lizEdition = function() { var eService = lizUrls.edition + '?' + new URLSearchParams(lizUrls.params); $.get(eService.replace('getFeature','deleteFeature'),{ layerId: aLayerId, - featureId: aFeatureId + featureId: aFeatureId, + linkedRecords: hasNToMRelations ? 'ntom' : '' }, function(data){ addEditionMessage( data, 'info', true); diff --git a/assets/src/legacy/map.js b/assets/src/legacy/map.js index 2ce39fddb3..7be63e2591 100644 --- a/assets/src/legacy/map.js +++ b/assets/src/legacy/map.js @@ -2088,178 +2088,309 @@ window.lizMap = function() { popupMaxFeatures == 0 ? 10 : popupMaxFeatures; getLayerFeature(featureType, fid, function(feat) { - // Array of Promise w/ fetch to request children popup content - const popupChidrenRequests = []; - const rConfigLayerAll = []; - - // Build POST query for every child based on QGIS relations - for ( const relation of relations ){ - const rLayerId = relation.referencingLayer; - const rGetLayerConfig = getLayerConfigById( rLayerId ); - if ( rGetLayerConfig ) { - const rConfigLayer = rGetLayerConfig[1]; - let clname = rConfigLayer?.shortname || rConfigLayer.cleanname; - if ( clname === undefined ) { - clname = cleanName(rConfigLayer.name); - rConfigLayer.cleanname = clname; - } - if ( rConfigLayer.popup == 'True' && self.parent().find('div.lizmapPopupChildren.'+clname).length == 0) { - let wmsName = rConfigLayer?.shortname || rConfigLayer.name; - const wmsOptions = { - 'LAYERS': wmsName - ,'QUERY_LAYERS': wmsName - ,'STYLES': '' - ,'SERVICE': 'WMS' - ,'VERSION': '1.3.0' - ,'CRS': (('crs' in rConfigLayer) && rConfigLayer.crs != '') ? rConfigLayer.crs : 'EPSG:4326' - ,'REQUEST': 'GetFeatureInfo' - ,'EXCEPTIONS': 'application/vnd.ogc.se_inimage' - ,'FEATURE_COUNT': popupMaxFeatures - ,'INFO_FORMAT': 'text/html' - }; - - if ( 'popupMaxFeatures' in rConfigLayer && !isNaN(parseInt(rConfigLayer.popupMaxFeatures)) ) - wmsOptions['FEATURE_COUNT'] = parseInt(rConfigLayer.popupMaxFeatures); - if ( wmsOptions['FEATURE_COUNT'] == 0 ) - wmsOptions['FEATURE_COUNT'] = popupMaxFeatures; - if ( rConfigLayer.request_params && rConfigLayer.request_params.filter && - rConfigLayer.request_params.filter !== '' ) - wmsOptions['FILTER'] = rConfigLayer.request_params.filter+' AND "'+relation.referencingField+'" = \''+feat.properties[relation.referencedField]+'\''; - else - wmsOptions['FILTER'] = wmsName+':"'+relation.referencingField+'" = \''+feat.properties[relation.referencedField]+'\''; - - var parentDiv = self.parent(); - - // Fetch queries - // Keep `rConfigLayer` in array with same order that fetch queries - // for later user when Promise.allSettled resolves - rConfigLayerAll.push(rConfigLayer); - popupChidrenRequests.push( - fetch(lizUrls.service, { - "method": "POST", - "body": new URLSearchParams(wmsOptions) - }).then(function (response) { - return response.text(); - }) - ); - } - } - } - - // Fetch GetFeatureInfo query for every children popups - Promise.allSettled(popupChidrenRequests).then(popupChildrenData => { - - const childPopupElements = []; - - for (let index = 0; index < popupChildrenData.length; index++) { - let popupChildData = popupChildrenData[index].value; - - var hasPopupContent = (!(!popupChildData || popupChildData == null || popupChildData == '')) - if (hasPopupContent) { - var popupReg = new RegExp('lizmapPopupTable', 'g'); - popupChildData = popupChildData.replace(popupReg, 'table table-condensed table-striped lizmapPopupTable'); - - const configLayer = rConfigLayerAll[index]; - - var clname = configLayer.cleanname; - if (clname === undefined) { - clname = cleanName(configLayer.name); - configLayer.cleanname = clname; - } - - const resizeTablesButtons = - ''+ - ''; - - var childPopup = $('
    ' + resizeTablesButtons + popupChildData + '
    '); - - //Manage if the user choose to create a table for children - if (['qgis', 'form'].indexOf(configLayer.popupSource) !== -1 && - childPopup.find('.lizmap_merged').length != 0) { - // save inputs - childPopup.find(".lizmapPopupDiv").each(function (i, e) { - var popupDiv = $(e); - if (popupDiv.find(".lizmapPopupHeader").prop("tagName") == 'TR') { - popupDiv.find(".lizmapPopupHeader").prepend(""); - popupDiv.find(".lizmapPopupHeader").next().prepend(""); - } else { - popupDiv.find(".lizmapPopupHeader").next().prepend(""); - } - popupDiv.find(".lizmapPopupHeader").next().children().first().append(popupDiv.find("input")); - }); - - childPopup.find("h4").each(function (i, e) { - if (i != 0) - $(e).remove(); - }); - - childPopup.find(".lizmapPopupHeader").each(function (i, e) { - if (i != 0) - $(e).remove(); - }); - - childPopup.find(".lizmapPopupDiv").contents().unwrap(); - childPopup.find(".lizmap_merged").contents().unwrap(); - childPopup.find(".lizmapPopupDiv").remove(); - childPopup.find(".lizmap_merged").remove(); - - childPopup.find(".lizmapPopupHidden").hide(); - - var tChildPopup = $("
    "); - childPopup.append(tChildPopup); - childPopup.find('tr').appendTo(tChildPopup); - - childPopup.children('tbody').remove(); - } - - var oldPopupChild = parentDiv.find('div.lizmapPopupChildren.' + clname); - if (oldPopupChild.length != 0){ - oldPopupChild.remove(); - } - - parentDiv.append(childPopup); - - childPopupElements.push(childPopup); - - // Trigger event for single popup children - lizMap.events.triggerEvent( - "lizmappopupchildrendisplayed", - { 'html': childPopup.html() } - ); - } - } - - // Handle compact-tables/explode-tables behaviour - $('.lizmapPopupChildren .popupAllFeaturesCompact table').DataTable(); - - $('.lizmapPopupChildren .compact-tables, .lizmapPopupChildren .explode-tables').tooltip(); - - $('.lizmapPopupChildren .compact-tables').off('click').on('click',function() { - $(this) - .addClass('hide') - .siblings('.explode-tables').removeClass('hide') - .siblings('.popupAllFeaturesCompact, .lizmapPopupSingleFeature').toggle(); - }); - - $('.lizmapPopupChildren .explode-tables').off('click').on('click',function () { - $(this) - .addClass('hide') - .siblings('.compact-tables').removeClass('hide') - .siblings('.popupAllFeaturesCompact, .lizmapPopupSingleFeature').toggle(); - }); - - // Trigger event for all popup children - lizMap.events.triggerEvent( - "lizmappopupallchildrendisplayed", - { - parentPopupElement: self.parents('.lizmapPopupSingleFeature'), - childPopupElements: childPopupElements - } - ); - }); - }); - }); - } + // Array of Promise w/ fetch to request children popup content + const popupChidrenRequests = []; + + // Array of pre-processed objects for WMS popup requests + const preProcessedRequests = []; + + // Array of object contains utilities for each relation + const preProcessUtilities = []; + + const rConfigLayerAll = []; + + // Build POST query for every child based on QGIS relations + for ( const relation of relations ){ + const rLayerId = relation.referencingLayer; + let preProcessRequest = null; + + // prepare utilities object + let rUtilities = { + rLayerId : rLayerId // pivot id or table id + }; + const pivotAttributeLayerConf = lizMap.getLayerConfigById( rLayerId, lizMap.config.attributeLayers, 'layerId' ); + // check if child is a pivot table + if (pivotAttributeLayerConf && pivotAttributeLayerConf[1]?.pivot == 'True' && config.relations.pivot && config.relations.pivot[rLayerId]) { + // looking for related children + const pivotConfig = lizMap.getLayerConfigById( + rLayerId, + config.layers, + 'id' + ); + if (pivotConfig) { + // n to m -> get "m" layer id + var mLayer = Object.keys(config.relations.pivot[rLayerId]).filter((k)=>{ return k !== layerId}) + if (mLayer.length == 1) { + // "m" layer config + const mLayerConfig = getLayerConfigById( mLayer[0] ); + if (mLayerConfig) { + let clRefname = mLayerConfig[1]?.shortname || mLayerConfig[1]?.cleanname; + if ( clRefname === undefined ) { + clRefname = cleanName(mLayerConfig[1].name); + mLayerConfig[1].cleanname = clRefname; + } + if (mLayerConfig[1].popup == 'True' && self.parent().find('div.lizmapPopupChildren.'+clRefname).length == 0) { + // get results from pivot table + const typeName = pivotConfig[1].typename; + const wfsParams = { + TYPENAME: typeName, + GEOMETRYNAME: 'extent' + }; + + wfsParams['EXP_FILTER'] = '"' + config.relations.pivot[rLayerId][layerId] + '" = ' + "'" + feat.properties[relation.referencedField] + "'";; + // Calculate bbox + if (config.options?.limitDataToBbox == 'True') { + wfsParams['BBOX'] = lizMap.mainLizmap.map.getView().calculateExtent(); + wfsParams['SRSNAME'] = lizMap.mainLizmap.map.getView().getProjection().getCode(); + } + preProcessRequest = lizMap.mainLizmap.wfs.getFeature(wfsParams); + + let ut = { + pivotTableId: rLayerId, + mLayerConfig: mLayerConfig + } + rUtilities = {...rUtilities,...ut}; + } + } + } + } + } else { + // one to n relation + const rGetLayerConfig = getLayerConfigById( rLayerId ); + if ( rGetLayerConfig ) { + preProcessRequest = { + oneToN:true, + layer:rGetLayerConfig[1] + } + let ut = { + referencingField: relation.referencingField, + referencedField: relation.referencedField + } + rUtilities = {...rUtilities, ...ut} + } + } + preProcessedRequests.push(preProcessRequest); + preProcessUtilities.push(rUtilities) + } + + Promise.allSettled(preProcessedRequests).then(preProcessResponses =>{ + for (let rr = 0; rr < preProcessResponses.length; rr++) { + const resp = preProcessResponses[rr]; + const utilities = preProcessUtilities[rr]; + if (resp.value) { + const respValue = resp.value; + var confLayer = null, wmsFilter = null; + if (respValue.oneToN && utilities.referencingField && utilities.referencedField) { + confLayer = respValue.layer; + wmsFilter = '"'+utilities.referencingField+'" = \''+feat.properties[utilities.referencedField]+'\''; + } else { + if (respValue.features) { + const features = respValue.features; + const referencedFieldForFilter = config.relations[utilities.mLayerConfig[1].id].filter((fil)=>{ + return fil.referencingLayer == utilities.rLayerId + })[0]?.referencedField; + let filArray = []; + const feats = {}; + features.forEach((feat)=>{ + var fid = feat.id.split('.')[1]; + feats[fid] = feat; + if (feat.properties && feat.properties[config.relations.pivot[utilities.rLayerId][utilities.mLayerConfig[1].id]]) { + filArray.push(feat.properties[config.relations.pivot[utilities.rLayerId][utilities.mLayerConfig[1].id]]) + } + }) + + if (filArray.length) { + let fil = filArray.map(function(val){ + return '"'+referencedFieldForFilter+'" = \''+val+'\''; + }) + + wmsFilter = fil.join(" OR "); + } + const pivotConfig = lizMap.getLayerConfigById( + utilities.pivotTableId, + config.layers, + 'id' + ); + pivotConfig[1].features = feats; + // get feature of mLayer + confLayer = utilities.mLayerConfig[1]; + } + } + if (wmsFilter && confLayer) { + const rConfigLayer = confLayer; + let clname = rConfigLayer?.shortname || rConfigLayer.cleanname; + if ( clname === undefined ) { + clname = cleanName(rConfigLayer.name); + rConfigLayer.cleanname = clname; + } + if ( rConfigLayer.popup == 'True' && self.parent().find('div.lizmapPopupChildren.'+clname).length == 0) { + let wmsName = rConfigLayer?.shortname || rConfigLayer.name; + const wmsOptions = { + 'LAYERS': wmsName + ,'QUERY_LAYERS': wmsName + ,'STYLES': '' + ,'SERVICE': 'WMS' + ,'VERSION': '1.3.0' + ,'CRS': (('crs' in rConfigLayer) && rConfigLayer.crs != '') ? rConfigLayer.crs : 'EPSG:4326' + ,'REQUEST': 'GetFeatureInfo' + ,'EXCEPTIONS': 'application/vnd.ogc.se_inimage' + ,'FEATURE_COUNT': popupMaxFeatures + ,'INFO_FORMAT': 'text/html' + }; + if ( 'popupMaxFeatures' in rConfigLayer && !isNaN(parseInt(rConfigLayer.popupMaxFeatures)) ) + wmsOptions['FEATURE_COUNT'] = parseInt(rConfigLayer.popupMaxFeatures); + if ( wmsOptions['FEATURE_COUNT'] == 0 ) + wmsOptions['FEATURE_COUNT'] = popupMaxFeatures; + if ( rConfigLayer.request_params && rConfigLayer.request_params.filter && + rConfigLayer.request_params.filter !== '' ) + wmsOptions['FILTER'] = rConfigLayer.request_params.filter+' AND '+wmsFilter; + else + wmsOptions['FILTER'] = wmsName+':'+wmsFilter; + + var parentDiv = self.parent(); + // Fetch queries + // Keep `rConfigLayer` in array with same order that fetch queries + // for later user when Promise.allSettled resolves + rConfigLayerAll.push(rConfigLayer); + popupChidrenRequests.push( + fetch(lizUrls.service, { + "method": "POST", + "body": new URLSearchParams(wmsOptions) + }).then(function (response) { + return response.text(); + }).then( function (textResp) { + // add utilities object to response for further controls + return { + popupChildData:textResp, + utilities:utilities + } + }) + ); + } + } + } + } + + // Fetch GetFeatureInfo query for every children popups + Promise.allSettled(popupChidrenRequests).then(popupChildrenData => { + + const childPopupElements = []; + + for (let index = 0; index < popupChildrenData.length; index++) { + let popupResponse = popupChildrenData[index].value; + let popupChildData = popupResponse.popupChildData; + const utilities = popupResponse.utilities; + var hasPopupContent = (!(!popupChildData || popupChildData == null || popupChildData == '')) + if (hasPopupContent) { + var popupReg = new RegExp('lizmapPopupTable', 'g'); + popupChildData = popupChildData.replace(popupReg, 'table table-condensed table-striped lizmapPopupTable'); + + const configLayer = rConfigLayerAll[index]; + + var clname = configLayer.cleanname; + if (clname === undefined) { + clname = cleanName(configLayer.name); + configLayer.cleanname = clname; + } + + if(utilities.pivotTableId){ + var popupFeatureToolbarReg = new RegExp(''+ + ''; + + var childPopup = $('
    ' + resizeTablesButtons + popupChildData + '
    '); + + // Manage if the user choose to create a table for children + if (['qgis', 'form'].indexOf(configLayer.popupSource) !== -1 && childPopup.find('.lizmap_merged').length != 0) { + // save inputs + childPopup.find(".lizmapPopupDiv").each(function (i, e) { + var popupDiv = $(e); + if (popupDiv.find(".lizmapPopupHeader").prop("tagName") == 'TR') { + popupDiv.find(".lizmapPopupHeader").prepend(""); + popupDiv.find(".lizmapPopupHeader").next().prepend(""); + } else { + popupDiv.find(".lizmapPopupHeader").next().prepend(""); + } + popupDiv.find(".lizmapPopupHeader").next().children().first().append(popupDiv.find("input")); + }); + childPopup.find("h4").each(function (i, e) { + if (i != 0) + $(e).remove(); + }); + + childPopup.find(".lizmapPopupHeader").each(function (i, e) { + if (i != 0) + $(e).remove(); + }); + + childPopup.find(".lizmapPopupDiv").contents().unwrap(); + childPopup.find(".lizmap_merged").contents().unwrap(); + childPopup.find(".lizmapPopupDiv").remove(); + childPopup.find(".lizmap_merged").remove(); + + childPopup.find(".lizmapPopupHidden").hide(); + + var tChildPopup = $("
    "); + childPopup.append(tChildPopup); + childPopup.find('tr').appendTo(tChildPopup); + + childPopup.children('tbody').remove(); + } + + var oldPopupChild = parentDiv.find('div.lizmapPopupChildren.' + clname); + if (oldPopupChild.length != 0) { + oldPopupChild.remove(); + } + + parentDiv.append(childPopup); + + childPopupElements.push(childPopup); + + // Trigger event for single popup children + lizMap.events.triggerEvent( + "lizmappopupchildrendisplayed", + { 'html': childPopup.html() } + ); + } + } + + // Handle compact-tables/explode-tables behaviour + $('.lizmapPopupChildren .popupAllFeaturesCompact table').DataTable(); + + $('.lizmapPopupChildren .compact-tables, .lizmapPopupChildren .explode-tables').tooltip(); + + $('.lizmapPopupChildren .compact-tables').off('click').on('click',function() { + $(this) + .addClass('hide') + .siblings('.explode-tables').removeClass('hide') + .siblings('.popupAllFeaturesCompact, .lizmapPopupSingleFeature').toggle(); + }); + + $('.lizmapPopupChildren .explode-tables').off('click').on('click',function () { + $(this) + .addClass('hide') + .siblings('.compact-tables').removeClass('hide') + .siblings('.popupAllFeaturesCompact, .lizmapPopupSingleFeature').toggle(); + }); + + // Trigger event for all popup children + lizMap.events.triggerEvent( + "lizmappopupallchildrendisplayed", + { + parentPopupElement: self.parents('.lizmapPopupSingleFeature'), + childPopupElements: childPopupElements + } + ); + }); + }) + }); + }); + } /** * diff --git a/lizmap/modules/lizmap/classes/qgisVectorLayer.class.php b/lizmap/modules/lizmap/classes/qgisVectorLayer.class.php index e566b2f3f5..2cb9a664f0 100644 --- a/lizmap/modules/lizmap/classes/qgisVectorLayer.class.php +++ b/lizmap/modules/lizmap/classes/qgisVectorLayer.class.php @@ -949,21 +949,26 @@ public function updateFeature($feature, $values, $loginFilteredLayers) } /** - * @param object $feature - * @param null|array $loginFilteredLayers array with these keys: - * - where: SQL WHERE statement - * - type: 'groups' or 'login' - * - attribute: filter attribute from the layer + * @param object $feature + * @param null|array $loginFilteredLayers array with these keys: + * - where: SQL WHERE statement + * - type: 'groups' or 'login' + * - attribute: filter attribute from the layer + * @param null|jDbConnection $connection DBConnection, if not null then the parameter conneciton is used, default value null * * @throws Exception * * @return int */ - public function deleteFeature($feature, $loginFilteredLayers) + public function deleteFeature($feature, $loginFilteredLayers, $connection = null) { // Get database connection object $dtParams = $this->getDatasourceParameters(); - $cnx = $this->getDatasourceConnection(); + if ($connection) { + $cnx = $connection; + } else { + $cnx = $this->getDatasourceConnection(); + } $dbFieldsInfo = $this->getDbFieldsInfo(); // SQL for deleting on line in the edition table diff --git a/lizmap/modules/lizmap/controllers/edition.classic.php b/lizmap/modules/lizmap/controllers/edition.classic.php index 86454564fb..ee7966c3f5 100644 --- a/lizmap/modules/lizmap/controllers/edition.classic.php +++ b/lizmap/modules/lizmap/controllers/edition.classic.php @@ -167,81 +167,138 @@ private function getEditionParameters($save = false) return false; } + $layerEditionParameters = $this->getLayerEditionParameter($lproj, $layerId, $featureIdParam); - /** @var qgisVectorLayer $layer The QGIS vector layer instance */ - $layer = $lproj->getLayer($layerId); - - if (!$layer) { - $this->setErrorMessage(jLocale::get('view~edition.message.error.layer.editable'), 'LayerNotEditable'); - + if (!$layerEditionParameters) { return false; } - $layerXml = $layer->getXmlLayer(); - $layerName = $layer->getName(); - // Verifying if the layer is editable - if (!$layer->isEditable()) { - $this->setErrorMessage(jLocale::get('view~edition.message.error.layer.editable'), 'LayerNotEditable'); + // Define class private properties + $this->project = $lproj; + $this->repository = $lrep; + $this->layerId = $layerId; - return false; + $this->featureId = $layerEditionParameters['featureId']; + $this->featureIdParam = $layerEditionParameters['featureIdParam']; + $this->layer = $layerEditionParameters['layer']; + $this->layerXml = $layerEditionParameters['layerXml']; + $this->layerName = $layerEditionParameters['layerName']; + + // Optionally filter data by login or/and by polygon + $this->loginFilteredOverride = jAcl2::check('lizmap.tools.loginFilteredLayers.override', $lrep->getKey()); + + $dbFieldsInfo = $this->layer->getDbFieldsInfo(); + $this->primaryKeys = $layerEditionParameters['primaryKeys']; + $this->geometryColumn = $layerEditionParameters['geometryColumn']; + $this->srid = $layerEditionParameters['srid']; + $this->proj4 = $layerEditionParameters['proj4']; + + return true; + } + + /** + * Get layer parameters and returns layers info for edition. + * + * @param null|Lizmap\Project\Project $proj the project + * @param null|string $lid the layer id + * @param null|string|string[] $fIdParams the feature ids + * @param bool $setMessage set/not set error message + * + * @return null|array{'layer': qgisVectorLayer, 'layerXml': simpleXMLElement, 'layerName': string, 'featureId': mixed, 'featureIdParam': string, 'dbFieldsInfo': qgisLayerDbFieldsInfo, 'primaryKeys': string[], 'geometryColumn': string, 'srid': mixed, 'proj4': mixed} + */ + protected function getLayerEditionParameter($proj, $lid, $fIdParams, $setMessage = true) + { + if (!$proj || !$lid) { + return null; } - if (!$layer->canCurrentUserEdit()) { - $this->setErrorMessage(jLocale::get('view~edition.access.denied'), 'AuthorizationRequired'); + /** @var null|qgisVectorLayer $player The QGIS vector layer instance */ + $player = $proj->getLayer($lid); - return false; + if (!$player) { + if ($setMessage) { + $this->setErrorMessage(jLocale::get('view~edition.message.error.layer.editable'), 'LayerNotEditable'); + } + + return null; } - // feature Id (optional, only for edition and save) - $featureId = $featureIdParam; - if ($featureIdParam) { - if (strpos($featureIdParam, ',') !== false) { - $featureId = preg_split('#,#', $featureIdParam); - } elseif (strpos($featureIdParam, '@@') !== false) { - $featureId = preg_split('#@@#', $featureIdParam); + $layerXml = $player->getXmlLayer(); + $layerName = $player->getName(); + + // Verifying if the layer is editable + if (!$player->isEditable()) { + if ($setMessage) { + $this->setErrorMessage(jLocale::get('view~edition.message.error.layer.editable'), 'LayerNotEditable'); } + + return null; } - // Define class private properties - $this->project = $lproj; - $this->repository = $lrep; - $this->layerId = $layerId; - $this->featureId = $featureId; - $this->featureIdParam = $featureIdParam; - $this->layer = $layer; - $this->layerXml = $layerXml; - $this->layerName = $layerName; + if (!$player->canCurrentUserEdit()) { + if ($setMessage) { + $this->setErrorMessage(jLocale::get('view~edition.access.denied'), 'AuthorizationRequired'); + } - // Optionally filter data by login or/and by polygon - $this->loginFilteredOverride = jAcl2::check('lizmap.tools.loginFilteredLayers.override', $lrep->getKey()); + return null; + } - $dbFieldsInfo = $this->layer->getDbFieldsInfo(); - $this->primaryKeys = $dbFieldsInfo->primaryKeys; - $this->geometryColumn = $dbFieldsInfo->geometryColumn; - $this->srid = $this->layer->getSrid(); - $this->proj4 = $this->layer->getProj4(); + // feature Id (optional, only for edition and save) + $featureId = $fIdParams; + if ($fIdParams) { + if (strpos($fIdParams, ',') !== false) { + $featureId = preg_split('#,#', $fIdParams); + } elseif (strpos($fIdParams, '@@') !== false) { + $featureId = preg_split('#@@#', $fIdParams); + } + } - return true; + $dbFieldsInfo = $player->getDbFieldsInfo(); + $primaryKeys = $dbFieldsInfo->primaryKeys; + $geometryColumn = $dbFieldsInfo->geometryColumn; + $srid = $player->getSrid(); + $proj4 = $player->getProj4(); + + return array( + 'layer' => $player, + 'layerXml' => $layerXml, + 'layerName' => $layerName, + 'featureId' => $featureId, + 'featureIdParam' => $fIdParams, + 'dbFieldsInfo' => $dbFieldsInfo, + 'primaryKeys' => $primaryKeys, + 'geometryColumn' => $geometryColumn, + 'srid' => $srid, + 'proj4' => $proj4, + ); } - /* + /** * Get the WFS feature for the editing object - * and set the controller property: featureData. + * and return featureData object. * * This method will always return an object if the feature exists in the layer * even if there are some filters by login or by polygon ! * /!\ This is not the responsibility of this method to know if the user has the right to edit ! * + * @param null|qgisVectorLayer $layer the qgis vector layer + * @param mixed $featureId the value to search + * @param null|mixed $keys primary keys or attribute filter + * @param string $exp_filter filter for attributes + * + * @return mixed */ - private function getWfsFeature() + private function getWfsFeature($layer, $featureId, $keys, $exp_filter = null) { - $featureId = $this->featureId; + if (!$layer || !$keys) { + return null; + } // Get features primary key field values corresponding to featureId(s) if (!empty($featureId) || $featureId === 0 || $featureId === '0') { - $typename = $this->layer->getShortName(); + $typename = $layer->getShortName(); if (!$typename || $typename == '') { - $typename = str_replace(' ', '_', $this->layer->getName()); + $typename = str_replace(' ', '_', $layer->getName()); } if (is_array($featureId)) { // QGIS3 (at least <=3.4) doesn't support pk with multiple fields @@ -252,17 +309,17 @@ private function getWfsFeature() } // We must give the fields used in the filters (featureid and exp_filter) - $propertyName = array_merge(array(), $this->primaryKeys); + $propertyName = array_merge(array(), $keys); if (!$this->loginFilteredOverride) { // login filter - $loginFilteredConfig = $this->project->getLoginFilteredConfig($this->layer->getName(), true); + $loginFilteredConfig = $this->project->getLoginFilteredConfig($layer->getName(), true); if ($loginFilteredConfig && property_exists($loginFilteredConfig, 'filterAttribute')) { $propertyName[] = $loginFilteredConfig->filterAttribute; } // polygon filter - $polygonFilter = $this->project->getLayerPolygonFilterConfig($this->layer->getName(), true); + $polygonFilter = $this->project->getLayerPolygonFilterConfig($layer->getName(), true); if ($polygonFilter && !in_array($polygonFilter['primary_key'], $propertyName)) { $propertyName[] = $polygonFilter['primary_key']; } @@ -280,13 +337,18 @@ private function getWfsFeature() 'FEATUREID' => $featureId, ); + if ($exp_filter) { + $wfs_params['EXP_FILTER'] = $exp_filter; + unset($wfs_params['FEATUREID']); + } + $wfs_request = new \Lizmap\Request\WFSRequest( $this->project, $wfs_params, lizmap::getServices() ); - $this->featureData = null; + $featureData = null; $wfs_response = $wfs_request->process(); // Check code @@ -298,13 +360,17 @@ private function getWfsFeature() return; } - $this->featureData = json_decode($wfs_response->getBodyAsString()); - if (empty($this->featureData)) { - $this->featureData = null; - } elseif (empty($this->featureData->features)) { - $this->featureData = null; + $featureData = json_decode($wfs_response->getBodyAsString()); + if (empty($featureData)) { + $featureData = null; + } elseif (empty($featureData->features)) { + $featureData = null; } + + return $featureData; } + + return null; } /** @@ -438,7 +504,7 @@ public function modifyFeature() } // Check if data has been fetched via WFS for the feature - $this->getWfsFeature(); + $this->featureData = $this->getWfsFeature($this->layer, $this->featureId, $this->primaryKeys); if (!$this->featureData) { jMessage::add(jLocale::get('view~edition.message.error.feature.get'), 'featureNotFoundViaWfs'); @@ -495,7 +561,7 @@ public function editFeature() } // Check if data has been fetched via WFS for the feature - $this->getWfsFeature(); + $this->featureData = $this->getWfsFeature($this->layer, $this->featureId, $this->primaryKeys); if (($this->featureId || $this->featureId === 0 || $this->featureId === '0') && !$this->featureData) { jMessage::add(jLocale::get('view~edition.message.error.feature.get'), 'featureNotFoundViaWfs'); @@ -630,7 +696,7 @@ public function saveFeature() } // Get the data via a WFS request - $this->getWfsFeature(); + $this->featureData = $this->getWfsFeature($this->layer, $this->featureId, $this->primaryKeys); // event to add custom field into the jForms form before setting data in it $eventParams = array( @@ -707,6 +773,15 @@ public function saveFeature() return $rep; } + // link mlayer to nlayer feature + if ($this->param('liz_pivot')) { + try { + $this->linkAddedFeature($this->param('liz_pivot'), $pkvals); + } catch (Exception $e) { + lizmap::getAppContext()->logMessage($e->getMessage(), 'lizmapadmin'); + jMessage::add($e->getMessage(), 'linkAddedFeatureError'); + } + } // Redirect to the edition form or to the validate message $next_action = $form->getData('liz_future_action'); jForms::destroy('view~edition', $this->featureId); @@ -781,6 +856,138 @@ public function saveFeature() return $rep; } + /** + * Utilities used for link features related via an n to m relationship after an new "m" feature is created. + * + * @param string $linkedLayers information on feaures to link provided as "pivot-id:n-layer-id:n-layer-value" + * @param array $featureIds the primary keys of the new "m" feature + * + * @throws Exception + */ + private function linkAddedFeature($linkedLayers, $featureIds) + { + // check pivot parameters + if (!$linkedLayers || count($featureIds) == 0) { + throw new Exception(jLocale::get('view~edition.linkaddedfeature.error.linkfeature.invalid.parameters')); + } + + // lkPar[0]-> pivotId + // lkPar[1]-> $nLayerId + // lkPar[2]-> referenced field value of father layer in the pivot table + $lkPar = explode(':', $linkedLayers); + + $pivotId = $lkPar[0]; + $n_layerId = $lkPar[1]; + $referencedFieldValue = $lkPar[2]; + + if (count($lkPar) !== 3 || !$pivotId || !$n_layerId || !$referencedFieldValue) { + throw new Exception(jLocale::get('view~edition.linkaddedfeature.error.linkfeature.invalid.parameters')); + } + + // check pivot && n_Layer conf + /** @var qgisVectorLayer $pivotLayer The pivot vector layer instance */ + $pivotLayer = $this->project->getLayer($pivotId); + if (!$pivotLayer) { + throw new Exception(jLocale::get('view~edition.linkaddedfeature.error.linkfeature.invalid.parameters')); + } + + /** @var qgisVectorLayer $n_layer The n_layer vector layer instance */ + $n_layer = $this->project->getLayer($n_layerId); + if (!$n_layer) { + throw new Exception(jLocale::get('view~edition.linkaddedfeature.error.linkfeature.invalid.parameters')); + } + + $pivotLayerName = $pivotLayer->getName(); + $n_layerName = $n_layer->getName(); + + // check if the layers is configured in attribute table + if ( + !$this->project->hasAttributeLayers() + || !$this->project->hasAttributeLayersForLayer($pivotLayerName) + || !$this->project->isPivotLayer($pivotLayerName) + || !$this->project->hasAttributeLayersForLayer($n_layerName) + + ) { + throw new Exception(jLocale::get('view~edition.linkaddedfeature.error.linkfeature.invalid.parameters')); + } + + // get project relations and perform necessary checks + $relations = $this->project->getRelations(); + if ( + !is_array($relations) + || !array_key_exists('pivot', $relations) + || !array_key_exists($pivotId, $relations['pivot']) + || !array_key_exists($this->layerId, $relations['pivot'][$pivotId]) + || !array_key_exists($n_layerId, $relations['pivot'][$pivotId]) + || !array_key_exists($n_layerId, $relations) + || !array_key_exists($this->layerId, $relations) + ) { + throw new Exception(jLocale::get('view~edition.linkaddedfeature.error.linkfeature.association', array($this->layer->getTitle(), $n_layer->getTitle()))); + } + + $pivotConf = $relations['pivot'][$pivotId]; + $m_layerId = $this->layerId; + + $n_relation = null; + $m_relation = null; + foreach ($relations as $kRel => $relation) { + if ($kRel !== 'pivot') { + if ($kRel == $m_layerId || $kRel == $n_layerId) { + if (is_array($relation)) { + $filtered = array_filter($relation, function ($ar) use ($pivotId, $pivotConf, $kRel) { + return is_array($ar) && array_key_exists('referencingLayer', $ar) && array_key_exists('referencingField', $ar) && $ar['referencingLayer'] == $pivotId && $ar['referencingField'] == $pivotConf[$kRel]; + }); + if (count($filtered) == 1) { + if ($kRel == $m_layerId && !$m_relation) { + // mLayer -> current layer on editing + $m_relation = reset($filtered); + } elseif ($kRel == $n_layerId && !$n_relation) { + // nLayer -> layer related to nLayer via the pivot pivotId + $n_relation = reset($filtered); + } + } + } + } + } + } + // check information on relations and primary keys + if ( + !$n_relation + || !$m_relation + || !array_key_exists($m_relation['referencedField'], $featureIds) + || !$featureIds[$m_relation['referencedField']] + ) { + throw new Exception(jLocale::get('view~edition.linkaddedfeature.error.linkfeature.association', array($this->layer->getTitle(), $n_layer->getTitle()))); + } + + $m_layerVal = $featureIds[$m_relation['referencedField']]; + $m_layerField = $m_relation['referencingField']; + $n_layerVal = $referencedFieldValue; + $n_layerField = $n_relation['referencingField']; + + // check if pivot is editable + $capabilities = $pivotLayer->getRealEditionCapabilities(); + if ($capabilities->createFeature != 'True' || !$pivotLayer->canCurrentUserEdit()) { + throw new Exception(jLocale::get('view~edition.linkaddedfeature.error.linkfeature.association', array($this->layer->getTitle(), $n_layer->getTitle()))); + } + + $dbFieldsInfo = $pivotLayer->getDbFieldsInfo(); + $dataFields = $dbFieldsInfo->dataFields; + + // Check fields on pivot + if (!array_key_exists($n_layerField, $dataFields) || !array_key_exists($m_layerField, $dataFields)) { + throw new Exception(jLocale::get('view~edition.linkaddedfeature.error.linkfeature.association', array($this->layer->getTitle(), $n_layer->getTitle()))); + } + + try { + $pivotLayer->insertRelations($n_layerField, array($n_layerVal), $m_layerField, array($m_layerVal)); + } catch (Exception $e) { + throw new Exception(jLocale::get('view~edition.linkaddedfeature.error.linkfeature.association', array($this->layer->getTitle(), $n_layer->getTitle()))); + } + + jMessage::add(jLocale::get('view~edition.linkaddedfeature.success.linkfeature', array($this->layer->getTitle(), $n_layer->getTitle())), 'linkAddedFeature'); + } + /** * Form close : destroy it and display a message. * @@ -811,9 +1018,25 @@ public function closeFeature() $rep = $this->getResponse('htmlfragment'); $tpl = new jTpl(); + $linkAddedFeatureError = jMessage::get('linkAddedFeatureError'); + $linkAddedFeature = jMessage::get('linkAddedFeature'); + + // check for linked features messagges + $addedFeatureMessage = null; + $addedFeatureError = false; + + if ($linkAddedFeatureError && is_array($linkAddedFeatureError)) { + $addedFeatureMessage = $linkAddedFeatureError[0]; + $addedFeatureError = true; + } elseif ($linkAddedFeature && is_array($linkAddedFeature)) { + $addedFeatureMessage = $linkAddedFeature[0]; + } + $closeFeatureMessage = jLocale::get('view~edition.form.data.saved'); $pkValsJson = $this->param('pkVals', ''); + $tpl->assign('addedFeatureMessage', $addedFeatureMessage); + $tpl->assign('addedFeatureError', $addedFeatureError); $tpl->assign('closeFeatureMessage', $closeFeatureMessage); $tpl->assign('pkValsJson', $pkValsJson); $content = $tpl->fetch('view~edition_close_feature_data'); @@ -826,6 +1049,7 @@ public function closeFeature() /** * Delete Feature (output as html fragment). + * If the Feature is linked with any pivot table (n to m relation) then the records on pivots are deleted too. * * @urlparam string $repository Lizmap Repository * @urlparam string $project Name of the project @@ -841,7 +1065,7 @@ public function deleteFeature() } // Check if data has been fetched via WFS for the feature - $this->getWfsFeature(); + $this->featureData = $this->getWfsFeature($this->layer, $this->featureId, $this->primaryKeys); if (!$this->featureData) { jMessage::add(jLocale::get('view~edition.message.error.feature.get'), 'featureNotFoundViaWfs'); @@ -901,37 +1125,150 @@ public function deleteFeature() if ($event->allResponsesByKeyAreTrue('filesDeleted')) { $deleteFiles = array(); } + $pivotFeatures = null; + if ($this->param('linkedRecords') && $this->param('linkedRecords') == 'ntom') { + // check all the pivot linked to the layer + $pivotFeatures = $this->getPivotFeatures(); + } try { - $feature = $this->featureData->features[0]; - // delete record in the database - $rs = $qgisForm->deleteFromDb($feature); - if ($rs) { - jMessage::add(jLocale::get('view~edition.message.success.delete'), 'success'); - $eventParams['deleteFiles'] = $deleteFiles; - $eventParams['success'] = true; - - // delete associated files to the record - foreach ($deleteFiles as $path) { - if (file_exists($path)) { - unlink($path); + // exec deletion in one transaction. + // This presumes that tables has the same datasource connection parameters. + // In order to use the same transaction, variable $cnx is passed along methods deleteFeature and deleteFromDb + $cnx = $this->layer->getDatasourceConnection(); + + $cnx->beginTransaction(); + + $relationsRemoved = true; + if ($pivotFeatures && count($pivotFeatures) > 0) { + foreach ($pivotFeatures as $pivotId => $pivotFeature) { + $currentPivotLayer = $pivotFeature['layer']; + foreach ($pivotFeature['features'] as $feature) { + // null value passed as loginFilterOverride -> is this necessary for pivot tables? + $rsp = $currentPivotLayer->deleteFeature($feature, null, $cnx); + if (!$rsp) { + $relationsRemoved = false; + + break; + } + } + if (!$relationsRemoved) { + break; } } + } + if ($relationsRemoved) { + $feature = $this->featureData->features[0]; + $rs = $qgisForm->deleteFromDb($feature, $cnx); + + if ($rs) { + jMessage::add(jLocale::get('view~edition.message.success.delete'), 'success'); + $eventParams['deleteFiles'] = $deleteFiles; + $eventParams['success'] = true; + + // delete associated files to the record + foreach ($deleteFiles as $path) { + if (file_exists($path)) { + unlink($path); + } + } + $cnx->commit(); + } else { + jMessage::add(jLocale::get('view~edition.message.error.delete'), 'error'); + $eventParams['success'] = false; + $cnx->rollback(); + } } else { jMessage::add(jLocale::get('view~edition.message.error.delete'), 'error'); $eventParams['success'] = false; + $cnx->rollback(); } } catch (Exception $e) { jLog::log('An error has been raised when saving form data edition to db:'.$e->getMessage(), 'lizmapadmin'); jLog::logEx($e, 'error'); jMessage::add(jLocale::get('view~edition.message.error.delete'), 'error'); $eventParams['success'] = false; + if (isset($cnx)) { + $cnx->rollback(); + } } jEvent::notify('LizmapEditionPostDelete', $eventParams); return $this->serviceAnswer(); } + /** + * Get Features via WFS from pivots connected to the current layer feature on editing. + * + * @return mixed $pivotFeature The array containing the features + */ + protected function getPivotFeatures() + { + $pivotFeature = array(); + $relations = $this->project->getRelations(); + + if (!is_array($relations)) { + return $pivotFeature; + } + if (!array_key_exists('pivot', $relations)) { + return $pivotFeature; + } + $pivots = $relations['pivot']; + + foreach ($pivots as $pivotId => $pivotReference) { + if (array_key_exists($this->layerId, $pivotReference) && array_key_exists($this->layerId, $relations)) { + // check relation validity + foreach ($relations[$this->layerId] as $rel) { + if ($rel['referencingLayer'] == $pivotId && $rel['referencingField'] == $pivotReference[$this->layerId] && in_array($rel['referencedField'], $this->primaryKeys)) { + // avoid duplications + if (!array_key_exists($pivotId, $pivotFeature)) { + // check for edition capabilities + $editionLayerInfo = $this->getLayerEditionParameter($this->project, $pivotId, null, false); + if ($editionLayerInfo && is_array($editionLayerInfo)) { + if (array_key_exists($rel['referencedField'], $editionLayerInfo['dbFieldsInfo']->dataFields)) { + $currentLayer = $editionLayerInfo['layer']; + // checks on attribute table + if ($this->project->hasAttributeLayers() && $this->project->hasAttributeLayersForLayer($editionLayerInfo['layerName']) && $this->project->isPivotLayer($editionLayerInfo['layerName'])) { + // build filter for pivot table + $exp_filter = '"'.$rel['referencingField'].'" = \''.$this->featureId.'\''; + // get records via WFS service + $featureData = $this->getWfsFeature($currentLayer, $this->featureId, array(0 => $rel['referencingField']), $exp_filter); + if ($featureData) { + $eCapabilities = $currentLayer->getRealEditionCapabilities(); + if ($eCapabilities->deleteFeature == 'True') { + foreach ($featureData->features as $feature) { + if (!$this->loginFilteredOverride) { + $is_editable = $currentLayer->isFeatureEditable($feature); + if (!$is_editable) { + continue; + } + } + $currentFeatureId = $feature->{$editionLayerInfo['primaryKeys'][0]}; + if (!$currentFeatureId && $currentFeatureId !== 0 && $currentFeatureId !== '0') { + continue; + } + if (!array_key_exists($pivotId, $pivotFeature)) { + $pivotFeature[$pivotId] = array( + 'layer' => $currentLayer, + 'features' => array(), + ); + } + $pivotFeature[$pivotId]['features'][] = $feature; + } + } + } + } + } + } + } + } + } + } + } + + return $pivotFeature; + } + /** * Save a new feature, without redirecting to an HTML response. * @@ -1107,7 +1444,7 @@ public function editableFeatures() return $rep; } - /** @var qgisVectorLayer $layer The QGIS vector layer instance */ + /** @var null|qgisVectorLayer $layer The QGIS vector layer instance */ $layer = $lproj->getLayer($layerId); if (!$layer) { $rep->data['message'] = jLocale::get('view~edition.message.error.layer.editable'); diff --git a/lizmap/modules/lizmap/lib/Form/QgisForm.php b/lizmap/modules/lizmap/lib/Form/QgisForm.php index 16f8454d79..c20232c9a8 100644 --- a/lizmap/modules/lizmap/lib/Form/QgisForm.php +++ b/lizmap/modules/lizmap/lib/Form/QgisForm.php @@ -1141,9 +1141,10 @@ protected function processWebDavUploadFile($form, $ref) /** * Delete the feature from the database. * - * @param mixed $feature + * @param mixed $feature + * @param null|\jDbConnection $cnx DBConnection, passed along QGISVectorLayer deleteFeature method */ - public function deleteFromDb($feature) + public function deleteFromDb($feature, $cnx = null) { if (!$this->dbFieldsInfo) { throw new \Exception('Delete from database can\'t be done for the layer "'.$this->layer->getName().'"!'); @@ -1169,7 +1170,7 @@ public function deleteFromDb($feature) if ($event->allResponsesByKeyAreFalse('cancelDelete') === false) { return 0; } - $result = $this->layer->deleteFeature($feature, $loginFilteredLayers); + $result = $this->layer->deleteFeature($feature, $loginFilteredLayers, $cnx); $this->appContext->eventNotify('LizmapEditionFeaturePostDelete', $eventParams); return $result; diff --git a/lizmap/modules/lizmap/lib/Project/Project.php b/lizmap/modules/lizmap/lib/Project/Project.php index fa3412f31e..73d4ee2aef 100644 --- a/lizmap/modules/lizmap/lib/Project/Project.php +++ b/lizmap/modules/lizmap/lib/Project/Project.php @@ -842,6 +842,18 @@ public function hasAttributeLayersForLayer($layerName) return property_exists($attributeLayers, $layerName); } + public function isPivotLayer($layerName) + { + $attributeLayers = $this->cfg->getAttributeLayers(); + if (property_exists($attributeLayers, $layerName)) { + $attributeLayer = $attributeLayers->{$layerName}; + + return property_exists($attributeLayer, 'pivot') && $attributeLayer->pivot == 'True'; + } + + return false; + } + public function hasFtsSearches() { // Virtual jdb profile corresponding to the layer database diff --git a/lizmap/modules/view/locales/en_US/dictionnary.UTF-8.properties b/lizmap/modules/view/locales/en_US/dictionnary.UTF-8.properties index 2859244d2a..6ea4e8c1df 100644 --- a/lizmap/modules/view/locales/en_US/dictionnary.UTF-8.properties +++ b/lizmap/modules/view/locales/en_US/dictionnary.UTF-8.properties @@ -60,6 +60,9 @@ edition.revertgeom.success=Geometry has been reversed. You can now save the form edition.confirm.launch.child.creation=You are about to create a new child object for the currently edited object. \nPlease check before that you have already saved your changes.\n\nDo you wish to continue? edition.point.coord.crs.layer=Layer edition.point.coord.crs.map=Map +edition.link.pivot.add=The new record will be linked to the feature id %f of %l layer +edition.confirm.pivot.unlink=Are you sure you want to unlink the selected feature from %l layer? +edition.confirm.pivot.delete=Are you sure you want to delete the selected feature? \n\nThe related links with the following layers:\n %s \n\nwill be also deleted externalsearch.search=Searching externalsearch.notfound=No results diff --git a/lizmap/modules/view/locales/en_US/edition.UTF-8.properties b/lizmap/modules/view/locales/en_US/edition.UTF-8.properties index 8cd87045c8..92a42698b2 100644 --- a/lizmap/modules/view/locales/en_US/edition.UTF-8.properties +++ b/lizmap/modules/view/locales/en_US/edition.UTF-8.properties @@ -104,3 +104,7 @@ link.error.sql=An error occurred while adding linked data. link.success=Selected features have been correctly linked. unlink.success=The child feature has correctly been unlinked. unlink.error.sql=An error occurred while removing link. + +linkaddedfeature.error.linkfeature.invalid.parameters=Required parameter for link the new feature are invalid +linkaddedfeature.error.linkfeature.association=The association of the new "%s" feature to the "%s" layer has failed +linkaddedfeature.success.linkfeature=The new feature of layer "%s" was successfully linked to the layer "%s" \ No newline at end of file diff --git a/lizmap/modules/view/templates/edition_close_feature_data.tpl b/lizmap/modules/view/templates/edition_close_feature_data.tpl index 8ca528afe7..2bbafcdd3d 100644 --- a/lizmap/modules/view/templates/edition_close_feature_data.tpl +++ b/lizmap/modules/view/templates/edition_close_feature_data.tpl @@ -1,6 +1,11 @@ +{if $addedFeatureMessage} + +{/if}