diff --git a/CHANGELOG-3.10.md b/CHANGELOG-3.10.md
index bf332e0f9f..c34f3f25b3 100644
--- a/CHANGELOG-3.10.md
+++ b/CHANGELOG-3.10.md
@@ -22,6 +22,7 @@ with some extra keywords: backend, tests, test, translation, funders, important
* Short link permalink functionality
* Edition - Support QGIS dynamic default-value expressions in edit forms, including geometry-based (`$x`, `$y`, `$area`, `$length`, `$geometry`) and field-referencing expressions (e.g. `"firstname" || ' ' || "lastname"`). Defaults are re-evaluated when the geometry is drawn/edited and when a referenced field changes, honoring QGIS's `applyOnUpdate` flag.
* UI - Auto-activate box selection when opening the selection tool
+* Filter - Per-layer filter-removal button next to each filtered layer in the legend (#1551)
### Security
@@ -44,10 +45,12 @@ with some extra keywords: backend, tests, test, translation, funders, important
* Map - WMS baselayers from QGIS layers now proxy through QGIS Server
* Popup - Place children features tables inside drag-and-drop relation placeholders
* Print - Respect cfg layout order in print panel
+* Filter - The "deactivate filter" button now clears filters on all filtered layers, not only the last one (#1551)
### Tests
* e2e snap edition: Enhance Snap panel functionalities
+* e2e: form filter - test deactivate-all button and per-layer unfilter icon in legend panel (#1551)
### Backend
diff --git a/assets/src/components/Treeview.js b/assets/src/components/Treeview.js
index 2175749ca5..4608b3a3d4 100644
--- a/assets/src/components/Treeview.js
+++ b/assets/src/components/Treeview.js
@@ -122,6 +122,10 @@ export default class Treeview extends HTMLElement {
+ ${layer.isFiltered
+ ? html` this._removeLayerFilter(layer)}>`
+ : ''
+ }
@@ -338,4 +342,10 @@ export default class Treeview extends HTMLElement {
event.preventDefault();
}
}
+
+ _removeLayerFilter(layer) {
+ lizMap.events.triggerEvent('layerfeatureremovefilter',
+ { 'featureType': layer.name }
+ );
+ }
}
diff --git a/assets/src/legacy/attributeTable.js b/assets/src/legacy/attributeTable.js
index 0c7fdd09a8..4228b5eb75 100644
--- a/assets/src/legacy/attributeTable.js
+++ b/assets/src/legacy/attributeTable.js
@@ -2646,6 +2646,24 @@ var lizAttributeTable = function() {
);
}
+ /**
+ * Whether at least one layer currently has an active filter.
+ * @returns {boolean}
+ */
+ function hasFilteredLayer() {
+ for ( var ln in config.layers ) {
+ var lc = config.layers[ln];
+ if ( Array.isArray(lc['filteredFeatures']) && lc['filteredFeatures'].length ) {
+ return true;
+ }
+ var rp = lc['request_params'];
+ if ( rp && (rp['exp_filter'] || rp['filter'] || rp['filtertoken']) ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
/**
*
* @param typeNamePile
@@ -2672,7 +2690,9 @@ var lizAttributeTable = function() {
applyEmptyLayerFilter( typeName, typeNamePile, typeNameFilter, typeNameDone, cascade );
}
- $('#layerActionUnfilter').toggle((lizMap.lizmapLayerFilterActive !== null));
+ // Keep the button visible while any layer is still filtered,
+ // not only when lizmapLayerFilterActive is set (#1551)
+ $('#layerActionUnfilter').toggle(hasFilteredLayer());
}
/**
@@ -2731,7 +2751,10 @@ var lizAttributeTable = function() {
function getPivotParam( typeNameId, attributeLayerConfig, typeNameDone ) {
var isPivot = false;
var pivotParam = null;
- if( 'pivot' in attributeLayerConfig
+ // attributeLayerConfig is null for layers without an attribute
+ // table config; applyEmptyLayerFilter passes it through (#1551)
+ if( attributeLayerConfig
+ && 'pivot' in attributeLayerConfig
&& attributeLayerConfig.pivot == 'True'
&& attributeLayerConfig.layerId in config.relations.pivot
){
diff --git a/assets/src/legacy/switcher-layers-actions.js b/assets/src/legacy/switcher-layers-actions.js
index 3e23fc727a..e2ecc04254 100644
--- a/assets/src/legacy/switcher-layers-actions.js
+++ b/assets/src/legacy/switcher-layers-actions.js
@@ -635,13 +635,38 @@ var lizLayerActionButtons = function() {
// Cancel Lizmap global filter
$('#layerActionUnfilter').click(function(){
- var layerName = lizMap.lizmapLayerFilterActive;
- if( !layerName )
+ // Collect every layer that currently has an active filter, not
+ // only the last one stored in lizmapLayerFilterActive (#1551).
+ // The selection tool can filter several layers at once.
+ var filteredLayers = [];
+ for (var lName in lizMap.config.layers) {
+ var lConfig = lizMap.config.layers[lName];
+ // Selection tool / attribute-table filter sets filteredFeatures
+ var lFilteredFeatures = lConfig['filteredFeatures'];
+ var hasFilteredFeatures = Array.isArray(lFilteredFeatures) && lFilteredFeatures.length;
+ // Form filter "simple" method sets a WMS filter on request_params
+ var rParams = lConfig['request_params'];
+ var hasRequestFilter = rParams
+ && (rParams['exp_filter'] || rParams['filter'] || rParams['filtertoken']);
+ if (hasFilteredFeatures || hasRequestFilter) {
+ filteredLayers.push(lName);
+ }
+ }
+
+ // Keep the legacy single active layer as a fallback
+ if (lizMap.lizmapLayerFilterActive
+ && filteredLayers.indexOf(lizMap.lizmapLayerFilterActive) === -1) {
+ filteredLayers.push(lizMap.lizmapLayerFilterActive);
+ }
+
+ if( !filteredLayers.length )
return false;
- lizMap.events.triggerEvent("layerfeatureremovefilter",
- { 'featureType': layerName}
- );
+ for (var i = 0; i < filteredLayers.length; i++) {
+ lizMap.events.triggerEvent("layerfeatureremovefilter",
+ { 'featureType': filteredLayers[i]}
+ );
+ }
lizMap.lizmapLayerFilterActive = null;
$(this).hide();
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 7312f903a9..62af84d6af 100644
--- a/lizmap/modules/view/locales/en_US/dictionnary.UTF-8.properties
+++ b/lizmap/modules/view/locales/en_US/dictionnary.UTF-8.properties
@@ -16,6 +16,7 @@ tree.button.checkbox=Display/Hide
tree.button.link=Open documentation
tree.button.removeCache=Remove server's cache for this layer
tree.button.removeCache.confirmation=Remove server's cache for this layer?
+tree.button.removeFilter=Remove the filter for this layer
tree.button.expand=Expand
tree.button.collapse=Collapse
diff --git a/tests/end2end/playwright/form-filter.spec.js b/tests/end2end/playwright/form-filter.spec.js
index e275d926be..538a4d4847 100644
--- a/tests/end2end/playwright/form-filter.spec.js
+++ b/tests/end2end/playwright/form-filter.spec.js
@@ -3,6 +3,12 @@ import { test, expect } from '@playwright/test';
import { ProjectPage } from './pages/project';
import { getEchoRequestParams } from './globals';
+/**
+ * Layer name as used in the treeview data-testid attribute
+ * @type {string}
+ */
+const LAYER_NAME = 'form filter (à)';
+
test.describe('Form filter', () => {
test.beforeEach(async ({ page }) => {
const project = new ProjectPage(page, 'form_filter');
@@ -100,3 +106,60 @@ test.describe('Form filter', () => {
await expect(page.locator('#ui-id-2 .ui-menu-item div')).toHaveText('monuments');
});
});
+
+test.describe('Form filter - Legend panel interactions', () => {
+ test.beforeEach(async ({ page }) => {
+ const project = new ProjectPage(page, 'form_filter');
+ await project.open();
+ // Open the form filter panel
+ await page.locator('#button-filter').click();
+ });
+
+ test('Deactivate all filters button in legend panel clears the active filter', async ({ page }) => {
+ // Apply a filter via the form filter panel
+ const getMapPromise = page.waitForRequest(/GetMap/);
+ await page.locator('#liz-filter-field-test_filter').selectOption('_uvres_d_art_et_monuments_de_l_espace_urbain');
+ await getMapPromise;
+
+ // Switch to the layer panel (switcher) — opening the filter panel hid it
+ await page.locator('#button-switcher').click();
+
+ // The "deactivate all filters" button in the layer legend panel must be visible
+ await expect(page.locator('#layerActionUnfilter')).toBeVisible();
+
+ // The layer node in the treeview must have the 'filtered' class
+ await expect(page.getByTestId(LAYER_NAME).locator('.node')).toContainClass('filtered');
+
+ // Click the deactivate-all button in the legend panel
+ const getMapAfterUnfilter = page.waitForRequest(/GetMap/);
+ await page.locator('#layerActionUnfilter').click();
+ await getMapAfterUnfilter;
+
+ // The button must be hidden and the 'filtered' class must be removed
+ await expect(page.locator('#layerActionUnfilter')).not.toBeVisible();
+ await expect(page.getByTestId(LAYER_NAME).locator('.node')).not.toContainClass('filtered');
+ });
+
+ test('Per-layer filter icon in legend removes the filter for that layer', async ({ page }) => {
+ // Apply a filter via the form filter panel
+ const getMapPromise = page.waitForRequest(/GetMap/);
+ await page.locator('#liz-filter-field-test_filter').selectOption('_uvres_d_art_et_monuments_de_l_espace_urbain');
+ await getMapPromise;
+
+ // Switch to the layer panel (switcher) — opening the filter panel hid it
+ await page.locator('#button-switcher').click();
+
+ // The per-layer icon-filter button must be visible inside the treeview node
+ await expect(page.getByTestId(LAYER_NAME).locator('.icon-filter')).toBeVisible();
+
+ // Click the per-layer icon-filter to remove the filter for that layer only
+ const getMapAfterUnfilter = page.waitForRequest(/GetMap/);
+ await page.getByTestId(LAYER_NAME).locator('.icon-filter').click();
+ await getMapAfterUnfilter;
+
+ // Filter must be gone: 'filtered' class removed, icon hidden, global button hidden
+ await expect(page.getByTestId(LAYER_NAME).locator('.node')).not.toContainClass('filtered');
+ await expect(page.getByTestId(LAYER_NAME).locator('.icon-filter')).not.toBeVisible();
+ await expect(page.locator('#layerActionUnfilter')).not.toBeVisible();
+ });
+});