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(); + }); +});