diff --git a/assets/src/components/FeaturesTable.js b/assets/src/components/FeaturesTable.js index c6e7f694b5..25d5df64ef 100644 --- a/assets/src/components/FeaturesTable.js +++ b/assets/src/components/FeaturesTable.js @@ -17,6 +17,15 @@ import { mainLizmap, mainEventDispatcher } from '../modules/Globals.js'; * @element lizmap-features-table * @fires features.table.item.dragged * @fires features.table.rendered + * @example Example of use + * + * + * "libsquart" + * + * */ export default class FeaturesTable extends HTMLElement { @@ -53,7 +62,7 @@ export default class FeaturesTable extends HTMLElement { // Sorting attribute and direction this.sortingField = this.getAttribute('sortingField'); const sortingOrder = this.getAttribute('sortingOrder'); - this.sortingOrder = (sortingOrder !== null && ['asc', 'desc'].includes(sortingOrder)) ? this.sortingField : 'asc'; + this.sortingOrder = (sortingOrder !== null && ['asc', 'desc'].includes(sortingOrder)) ? sortingOrder : 'asc'; // open popup ? this.openPopup = (this.layerConfig && this.layerConfig.popup); @@ -65,6 +74,9 @@ export default class FeaturesTable extends HTMLElement { // Features this.features = []; + // Additional Fields JSON + this.additionalFields = {field:[]}; + // Clicked item feature ID this.activeItemFeatureId = null; @@ -82,8 +94,18 @@ export default class FeaturesTable extends HTMLElement { fields += ',' + this.sortingField; } + let uniqueAdditionalFields = []; + + // Create a unique JSON object for PHP request + if (!this.isAdditionalFieldsEmpty()) { + uniqueAdditionalFields = {}; + + this.additionalFields.field.forEach(field => { + uniqueAdditionalFields[field.alias] = field.expression; + }); + } // Get the features corresponding to the given parameters from attributes - mainLizmap.featuresTable.getFeatures(this.layerId, this.expressionFilter, this.withGeometry, fields) + mainLizmap.featuresTable.getFeatures(this.layerId, this.expressionFilter, this.withGeometry, fields, uniqueAdditionalFields) .then(displayExpressions => { // Check for errors if (!('status' in displayExpressions)) return; @@ -106,7 +128,7 @@ export default class FeaturesTable extends HTMLElement { // If an error occurred, replace empty content with error if (displayExpressions.status != 'success') { - this.querySelector('div.lizmap-features-table-container').innerHTML = `

+ this.querySelector('table.lizmap-features-table-container').innerHTML = `

${displayExpressions.error}

`; } @@ -176,7 +198,7 @@ export default class FeaturesTable extends HTMLElement { // If there is not features, add empty content in the container if (this.features.length === 0) { - this.querySelector('div.lizmap-features-table-container').innerHTML = ' '; + this.querySelector('table.lizmap-features-table-container').innerHTML = ' '; } // Add drag & drop capabilities if option is set @@ -204,6 +226,7 @@ export default class FeaturesTable extends HTMLElement { * @param {number} lineId Line number of the item in the features table */ onItemClick(event, feature) { + if (!this.openPopup) {return true;} // Check if the item was active @@ -213,19 +236,22 @@ export default class FeaturesTable extends HTMLElement { const activeItemTitle = `${this.openPopup ? lizDict['featuresTable.item.active.hover']: ''}`; const defaultItemTitle = `${this.openPopup ? lizDict['featuresTable.item.hover'] + '.': ''} ${this.itemsDraggable == 'yes' ? lizDict['featuresTable.item.draggable.hover'] + '.' : ''}`; + // Fix event.target depending on which HTML tag we click on + const eventTarget = event.currentTarget; + if (!itemWasActive) { // Set the features table properties - const lineId = parseInt(event.target.dataset.lineId); + const lineId = parseInt(eventTarget.dataset.lineId); this.activeItemFeatureId = feature.properties.feature_id; this.activeItemLineNumber = lineId; // Get popup data and display it mainLizmap.featuresTable.openPopup( - event.target.dataset.layerId, + eventTarget.dataset.layerId, feature, this.uniqueField, - event.target.parentElement.parentElement.querySelector('div.lizmap-features-table-item-popup'), + eventTarget.parentElement.parentElement.parentElement.querySelector('div.lizmap-features-table-item-popup'), function(aLayerId, aFeature, aTarget) { // Add bootstrap classes to the popup tables const popupTable = aTarget.querySelector('table.lizmapPopupTable'); @@ -241,14 +267,14 @@ export default class FeaturesTable extends HTMLElement { // Remove popup-displayed for all other items // And restore previous title - var items = featuresTableDiv.querySelectorAll('div.lizmap-features-table-container div.lizmap-features-table-item.popup-displayed'); + var items = featuresTableDiv.querySelectorAll('table.lizmap-features-table-container tr.lizmap-features-table-item.popup-displayed'); Array.from(items).forEach(item => { item.classList.remove('popup-displayed'); item.setAttribute('title', defaultItemTitle); }); // Add class to the active item - const childSelector = `div.lizmap-features-table-item[data-feature-id="${feature.properties.feature_id}"]`; + const childSelector = `tr.lizmap-features-table-item[data-feature-id="${feature.properties.feature_id}"]`; const activeItem = featuresTableDiv.querySelector(childSelector); if (activeItem) activeItem.classList.add('popup-displayed'); @@ -268,9 +294,9 @@ export default class FeaturesTable extends HTMLElement { this.activeItemFeatureId = null; this.activeItemLineNumber = null; - event.target.classList.remove('popup-displayed'); - event.target.setAttribute('title', defaultItemTitle); - event.target.closest('div.lizmap-features-table').classList.remove('popup-displayed'); + eventTarget.classList.remove('popup-displayed'); + eventTarget.setAttribute('title', defaultItemTitle); + eventTarget.closest('div.lizmap-features-table').classList.remove('popup-displayed'); } } @@ -282,7 +308,7 @@ export default class FeaturesTable extends HTMLElement { */ addDragAndDropCapabilities() { // Add drag and drop events to table items - const items = this.querySelectorAll('div.lizmap-features-table-container div.lizmap-features-table-item'); + const items = this.querySelectorAll('table.lizmap-features-table-container tr.lizmap-features-table-item'); if (!items) return; Array.from(items).forEach(item => { @@ -359,7 +385,7 @@ export default class FeaturesTable extends HTMLElement { // Send event const movedFeatureId = dropped.dataset.featureId; - const newItem = item.parentElement.querySelector(`div.lizmap-features-table-item[data-feature-id="${movedFeatureId}"]`); + const newItem = item.parentElement.querySelector(`tr.lizmap-features-table-item[data-feature-id="${movedFeatureId}"]`); /** * When the user has dropped an item in a new position * @event features.table.item.dragged @@ -396,6 +422,25 @@ export default class FeaturesTable extends HTMLElement { connectedCallback() { + if (document.querySelector("lizmap-field")) { + const listField = document.querySelectorAll("lizmap-field"); + + listField.forEach((field) => { + // Prevent all fields goes on one tab instead of the other when multiple layers are clicked on + if (JSON.stringify(this.additionalFields.field).includes(btoa(field.getAttribute('alias')))) { + return; + } + + this.additionalFields.field.push({ + 'alias': btoa(field.getAttribute('alias')), + 'expression': field.innerText, + 'description': field.getAttribute('description') + }); + + field.remove(); + }); + } + // Template this._template = () => html`
{ // Click on the previous item const lineNumber = this.activeItemLineNumber - 1; - const featureDiv = this.querySelector(`div.lizmap-features-table-item[data-line-id="${lineNumber}"]`); + const featureDiv = this.querySelector(`tr.lizmap-features-table-item[data-line-id="${lineNumber}"]`); if (featureDiv) featureDiv.click(); }}>
-
- ${this.features.map((feature, idx) => - html` -
{ - this.onItemClick(event, feature); - }} - >${feature.properties.display_expression} -
- ` - )} -
+ + ${this.buildLabels()} + + ${this.features.map((feature, idx) => + html` + { + this.onItemClick(event, feature); + }} + > + ${this.buildColumns(feature.properties)} + + ` + )} + +
`; @@ -452,6 +501,118 @@ export default class FeaturesTable extends HTMLElement { this.load(); } + /** + * Build the columns of the table + * @param properties - Object containing the properties of the feature + * @returns {TemplateResult<1>} The columns of the table + */ + buildColumns(properties) { + + let result = html` + ${this.buildDisplayExpressionColumn(properties)} + `; + + if (!this.isAdditionalFieldsEmpty()) { + this.additionalFields.field.forEach(field => { + let td = html` + + ${properties[field.alias]} + + `; + result = html` + ${result} + ${td} + `; + }); + } + + return result; + } + + /** + * Initialize tab with the first column "display_expression" + * @param {object} properties - Object containing the properties of the feature + * @returns {TemplateResult<1>} The first column of the table + */ + buildDisplayExpressionColumn(properties) { + if (this.isGeneralLabelExisting()) { + return html` + + ${properties.display_expression} + + `; + } else { + return html``; + } + } + + /** + * Initialize the labels of the table + * @returns {TemplateResult<1>} The labels of the table + */ + buildLabels() { + if (this.isAdditionalFieldsEmpty()) { + return html``; + } + + let result; + + this.additionalFields.field.forEach(field => { + let th = html` + + ${atob(field.alias)} + + `; + result = html` + ${result} + ${th} + `; + }); + + if (this.isGeneralLabelExisting()) { + // First th to create an empty column for "display_expression" + return html` + + + + ${result} + + + `; + } else { + return html` + + + ${result} + + + `; + } + + + } + + /** + * Check if the additionalFields property is empty + * @returns {boolean} True if the additionalFields property is empty + */ + isAdditionalFieldsEmpty() { + return this.additionalFields.field.length === 0; + } + + /** + * Check if the general label "display_expression" is existing + * @returns {boolean} True if the general label "display_expression" is existing + */ + isGeneralLabelExisting() { + return this.features[0].properties.hasOwnProperty('display_expression'); + } + static get observedAttributes() { return ['updated']; } attributeChangedCallback(name, oldValue, newValue) { diff --git a/assets/src/modules/FeaturesTable.js b/assets/src/modules/FeaturesTable.js index f481d3e266..b206bc9f19 100644 --- a/assets/src/modules/FeaturesTable.js +++ b/assets/src/modules/FeaturesTable.js @@ -32,13 +32,14 @@ export default class FeaturesTable { * @param {string|null} filter An QGIS expression filter * @param {boolean} withGeometry If we need to get the geometry * @param {string|null} fields List of field names separated by comma + * @param {object|array} additionalFields JSON object with the field names and expressions * * @returns — A Promise that resolves with the result of parsing the response body text as JSON. * @throws — {ResponseError} In case of invalid content type (not application/json or application/vnd.geo+json) or Invalid JSON * @throws — {HttpError} In case of not successful response (status not in the range 200 – 299) * @throws — {NetworkError} In case of catch exceptions */ - getFeatures(layerId, filter = null, withGeometry = false, fields = 'null') { + getFeatures(layerId, filter = null, withGeometry = false, fields = 'null', additionalFields = []) { // Build URL const url = `${lizUrls.service.replace('service?','features/displayExpression?')}&`; @@ -49,7 +50,7 @@ export default class FeaturesTable { formData.append('exp_filter', filter); formData.append('with_geometry', withGeometry.toString()); formData.append('fields', fields); - + formData.append('additionalFields',JSON.stringify(additionalFields)); // Return promise return Utils.fetchJSON(url, { method: "POST", diff --git a/lizmap/modules/lizmap/controllers/features.classic.php b/lizmap/modules/lizmap/controllers/features.classic.php index 53effbc933..9669c53cc8 100644 --- a/lizmap/modules/lizmap/controllers/features.classic.php +++ b/lizmap/modules/lizmap/controllers/features.classic.php @@ -154,6 +154,22 @@ public function displayExpression() // Filter $exp_filter = trim($this->param('exp_filter', 'FALSE')); + // AdditionalFields + try { + $additionalFields = json_decode($this->param('additionalFields', '[]'), true); + } catch (\Exception $e) { + $content['error'] = 'An error occurred while replacing the expression text !'; + $rep->data = $content; + + return $rep; + } + + foreach ($additionalFields as $key => $value) { + $additionalFields[$key] = $this->regexCheck($value); + } + + $expressions = array_merge($expressions, $additionalFields); + // Get the evaluated features for the given layer and parameters $getDisplayExpressions = qgisExpressionUtils::virtualFields( $qgisLayer, @@ -182,4 +198,27 @@ public function displayExpression() return $rep; } + + /** + * Check if the expression is valid. + * + * @param $expression string The expression to check + * + * @return string The expression if it is valid, 'INVALID EXPRESSION' otherwise + */ + private function regexCheck($expression): string + { + // Allowing only fields like "libsquart" and not expressions like rand(10,34) + $regex = '/^[a-zA-Z0-9_\\- ]+$/'; + + $expression = str_replace('"', '', $expression); + + preg_match($regex, $expression, $matches); + + if (count($matches) == 0) { + return "'INVALID EXPRESSION'"; + } + + return '"'.$expression.'"'; + } } diff --git a/lizmap/www/assets/css/map.css b/lizmap/www/assets/css/map.css index 4bfe844397..bb5874a4ea 100644 --- a/lizmap/www/assets/css/map.css +++ b/lizmap/www/assets/css/map.css @@ -3603,8 +3603,7 @@ lizmap-features-table h4 { div.lizmap-features-table { position: relative; } -div.lizmap-features-table-container { - border: 1px solid var(--color-contrasted-elements); +table.lizmap-features-table-container { font-size: 0.9em; margin: 0px 10px 0px 10px; position: relative; @@ -3612,35 +3611,20 @@ div.lizmap-features-table-container { overflow: auto; } -/* Feature item */ -div.lizmap-features-table-container div.lizmap-features-table-item { - padding: 5px; - border-top: 1px solid var(--color-contrasted-elements); - position: relative; - /* Invert colors on hover & focus */ - &:hover, &:focus, &:active, &.popup-displayed { - background-color: var(--color-contrasted-elements); - color: var(--color-contrasted-text); - } - /* Do not render the top border for the first item (parent already has a border) */ - &:first-child { - border-top: none; - } -} /* Pointer cursor */ -div.lizmap-features-table-container div.lizmap-features-table-item.has-action { +table.lizmap-features-table-container tr.lizmap-features-table-item.has-action { cursor: pointer; } + /* Hide other children if only one child feature is active */ -div.lizmap-features-table.popup-displayed div.lizmap-features-table-container div.lizmap-features-table-item { +div.lizmap-features-table.popup-displayed table.lizmap-features-table-container tr.lizmap-features-table-item { display: none; } /* In this context, display only the active child item */ -div.lizmap-features-table.popup-displayed div.lizmap-features-table-container div.lizmap-features-table-item.popup-displayed { - display: block; -} -/* Toolbar visible when popup is visible */ +div.lizmap-features-table.popup-displayed table.lizmap-features-table-container tr.lizmap-features-table-item.popup-displayed { + display: revert; +}/* Toolbar visible when popup is visible */ div.lizmap-features-table div.lizmap-features-table-toolbar { display: none; } @@ -3686,17 +3670,17 @@ div.lizmap-features-table-toolbar button.next-popup { /* Popup div, visible only if popup-dsplayed class is present for it preceeding sibling */ -div.lizmap-features-table div.lizmap-features-table-container + div.lizmap-features-table-item-popup { +div.lizmap-features-table table.lizmap-features-table-container + div.lizmap-features-table-item-popup { display: none; min-height: 200px; min-width: 200px; overflow: auto; } -div.lizmap-features-table.popup-displayed div.lizmap-features-table-container + div.lizmap-features-table-item-popup { +div.lizmap-features-table.popup-displayed table.lizmap-features-table-container + div.lizmap-features-table-item-popup { display: block; } /* Hide title of the popup, since it is already visible above */ -div.lizmap-features-table.popup-displayed div.lizmap-features-table-container + div.lizmap-features-table-item-popup h4.lizmapPopupTitle { +div.lizmap-features-table.popup-displayed table.lizmap-features-table-container + div.lizmap-features-table-item-popup h4.lizmapPopupTitle { display: none; } .hide { diff --git a/tests/end2end/playwright/lizmap-features-table.spec.js b/tests/end2end/playwright/lizmap-features-table.spec.js index e01e5c27eb..15812d166a 100644 --- a/tests/end2end/playwright/lizmap-features-table.spec.js +++ b/tests/end2end/playwright/lizmap-features-table.spec.js @@ -34,12 +34,12 @@ test.describe('Display lizmap-features-table component in popup from QGIS toolti await expect(lizmapFeaturesTable.locator("h4")).toHaveText("child sub-districts"); // Check items count - await expect(lizmapFeaturesTable.locator("div.lizmap-features-table-container div.lizmap-features-table-item")).toHaveCount(10); + await expect(lizmapFeaturesTable.locator("table.lizmap-features-table-container tr.lizmap-features-table-item")).toHaveCount(10); // Get first item and check it - let firstItem = lizmapFeaturesTable.locator("div.lizmap-features-table-container div.lizmap-features-table-item").first(); + let firstItem = lizmapFeaturesTable.locator("table.lizmap-features-table-container tr.lizmap-features-table-item").first(); await expect(firstItem).toHaveAttribute('data-line-id', '1'); - await expect(firstItem).toHaveAttribute('data-feature-id', '10'); + await expect(firstItem).toHaveAttribute('data-feature-id', '17'); // Click on first item and check sub-popup firstItem.click(); @@ -47,13 +47,13 @@ test.describe('Display lizmap-features-table component in popup from QGIS toolti await expect(firstItem).toHaveClass(/popup-displayed/); let popupContainer = lizmapFeaturesTable.locator('div.lizmap-features-table-item-popup'); await expect(popupContainer).toBeVisible(); - await expect(popupContainer.locator('table.lizmapPopupTable tbody tr:first-child td')).toHaveText('10'); + await expect(popupContainer.locator('table.lizmapPopupTable tbody tr:first-child td')).toHaveText('17'); // Next item let nextItemButton = lizmapFeaturesTable.locator('div.lizmap-features-table-toolbar button.next-popup'); nextItemButton.click(); await expect(popupContainer).toBeVisible(); - await expect(popupContainer.locator('table.lizmapPopupTable tbody tr:first-child td')).toHaveText('8'); + await expect(popupContainer.locator('table.lizmapPopupTable tbody tr:first-child td')).toHaveText('9'); // Close Item let closeItemButton = lizmapFeaturesTable.locator('div.lizmap-features-table-toolbar button.close-popup'); @@ -62,6 +62,15 @@ test.describe('Display lizmap-features-table component in popup from QGIS toolti await expect(lizmapFeaturesTable.locator('div.lizmap-features-table')).not.toHaveClass(/popup-displayed/); await expect(firstItem).not.toHaveClass(/popup-displayed/); + // Drag and Drop Item + await page.locator('.lizmap-features-table-container > tbody > tr:nth-child(2)').dragTo(page.locator('.lizmap-features-table-container > tbody > tr:first-child')); + + await expect(firstItem).toHaveAttribute('data-line-id', '1'); + await expect(firstItem).toHaveAttribute('data-feature-id', '9'); + + let secondItem = lizmapFeaturesTable.locator(".lizmap-features-table-container > tbody > tr:nth-child(2)"); + await expect(secondItem).toHaveAttribute('data-line-id', '2'); + await expect(secondItem).toHaveAttribute('data-feature-id', '17'); //clear screen