diff --git a/geonode_mapstore_client/client/js/components/MediaViewer/Media.jsx b/geonode_mapstore_client/client/js/components/MediaViewer/Media.jsx index 9aefacf11c..a532c19baf 100644 --- a/geonode_mapstore_client/client/js/components/MediaViewer/Media.jsx +++ b/geonode_mapstore_client/client/js/components/MediaViewer/Media.jsx @@ -9,6 +9,7 @@ import React, { Suspense, lazy } from 'react'; import MediaComponent from '@mapstore/framework/components/geostory/media'; import PdfViewer from '@js/components/MediaViewer/PdfViewer'; +import SpreadsheetViewer from '@js/components/MediaViewer/SpreadsheetViewer'; import { determineResourceType } from '@js/utils/FileUtils'; import Loader from '@mapstore/framework/components/misc/Loader'; import MainEventView from '@js/components/MainEventView'; @@ -30,6 +31,7 @@ const mediaMap = { gltf: Scene3DViewer, ifc: Scene3DViewer, audio: MediaComponent, + excel: SpreadsheetViewer, unsupported: UnsupportedViewer }; @@ -73,6 +75,8 @@ const Media = ({ resource, ...props }) => { url={resource ? metadataPreviewUrl(resource) : ''} isExternalSource={isDocumentExternalSource(resource)} bboxPolygon={resource?.ll_bbox_polygon} + title={resource.title} + extension={resource.extension} /> ); } diff --git a/geonode_mapstore_client/client/js/components/MediaViewer/SpreadsheetViewer.jsx b/geonode_mapstore_client/client/js/components/MediaViewer/SpreadsheetViewer.jsx new file mode 100644 index 0000000000..f5ce3c2275 --- /dev/null +++ b/geonode_mapstore_client/client/js/components/MediaViewer/SpreadsheetViewer.jsx @@ -0,0 +1,100 @@ +/* + * Copyright 2025, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useEffect, useState, Suspense, lazy } from "react"; + +import Loader from "@mapstore/framework/components/misc/Loader"; +import Message from "@mapstore/framework/components/I18N/Message"; + +import MetadataPreview from "@js/components/MetadataPreview/MetadataPreview"; +import { parseCSVToArray } from "@js/utils/FileUtils"; +const AdaptiveGrid = lazy(() => import("@mapstore/framework/components/misc/AdaptiveGrid")); + +const VirtualizedGrid = ({data}) => { + let [columns, ...rows] = data ?? []; + columns = columns?.map((column, index) => ({ key: index, name: column, resizable: true })) ?? []; + const rowGetter = rowNumber => rows?.[rowNumber]; + return ( +
+ + } + minColumnWidth={100} + /> + +
+ ); +}; + +export const SpreadsheetViewer = ({extension, title, description, src, url}) => { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (src) { + setLoading(true); + fetch(src) + .then(response => extension === "csv" + ? response.text() + : response.arrayBuffer() + ) + .then((res) => { + let response = res; + if (extension !== "csv") { + import('xlsx').then(({ read, utils }) => { + const workbook = read(response, { type: "array" }); + + // Convert first sheet to CSV + const sheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[sheetName]; + response = utils.sheet_to_csv(worksheet); + setData(parseCSVToArray(response)); + }).catch((e) => { + console.error("Failed to load xlsx module", e); + }); + } else { + setData(parseCSVToArray(response)); + } + }).catch(() => { + setError(true); + }).finally(()=> { + setLoading(false); + }); + } + }, [src]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( + + ); + } + + return data?.length > 0 ? ( +
+
+ {title} + {description} + +
+
+ ) : null; +}; + +export default SpreadsheetViewer; diff --git a/geonode_mapstore_client/client/js/utils/FileUtils.js b/geonode_mapstore_client/client/js/utils/FileUtils.js index 3594a385f6..50a71b67f4 100644 --- a/geonode_mapstore_client/client/js/utils/FileUtils.js +++ b/geonode_mapstore_client/client/js/utils/FileUtils.js @@ -39,6 +39,7 @@ export const videoExtensions = ['mp4', 'mpg', 'avi', 'm4v', 'mp2', '3gp', 'flv', export const audioExtensions = ['mp3', 'wav', 'ogg']; export const gltfExtensions = ['glb', 'gltf']; export const ifcExtensions = ['ifc']; +export const spreedsheetExtensions = ['csv', 'xls', 'xlsx']; /** * check if a resource extension is supported for display in the media viewer @@ -53,6 +54,7 @@ export const determineResourceType = extension => { if (ifcExtensions.includes(extension)) return 'ifc'; if (ifcExtensions.includes(extension)) return 'ifc'; if (audioExtensions.includes(extension)) return 'video'; + if (spreedsheetExtensions.includes(extension)) return 'excel'; return 'unsupported'; }; @@ -109,3 +111,25 @@ export const getFilenameFromContentDispositionHeader = (contentDisposition) => { } return ''; }; + +/** + * Identify the delimiter used in a CSV string + * Based on https://github.com/Inist-CNRS/node-csv-string + * @param {string} input + * @returns {string} delimiter + */ +export const detectCSVDelimiter = (input) => { + const separators = [',', ';', '|', '\t']; + const idx = separators + .map((separator) => input.indexOf(separator)) + .reduce((prev, cur) => + prev === -1 || (cur !== -1 && cur < prev) ? cur : prev + ); + return input[idx] || ','; +}; + +export const parseCSVToArray = (response) => { + if (isEmpty(response)) return []; + const delimiter = detectCSVDelimiter(response); + return response?.split('\n')?.map(row => row?.split(delimiter)) ?? []; +}; diff --git a/geonode_mapstore_client/client/js/utils/__tests__/FileUtils-test.js b/geonode_mapstore_client/client/js/utils/__tests__/FileUtils-test.js index 30a1492efb..5abd65a748 100644 --- a/geonode_mapstore_client/client/js/utils/__tests__/FileUtils-test.js +++ b/geonode_mapstore_client/client/js/utils/__tests__/FileUtils-test.js @@ -1,9 +1,11 @@ import expect from 'expect'; import { + detectCSVDelimiter, determineResourceType, getFileNameAndExtensionFromUrl, getFileNameParts, - getFilenameFromContentDispositionHeader + getFilenameFromContentDispositionHeader, + parseCSVToArray } from '@js/utils/FileUtils'; describe('FileUtils', () => { @@ -31,6 +33,14 @@ describe('FileUtils', () => { const mediaType = determineResourceType('mp3'); expect(mediaType).toEqual('video'); }); + it('should return excel if extension is a supported spreadsheet format', () => { + let mediaType = determineResourceType('csv'); + expect(mediaType).toEqual('excel'); + mediaType = determineResourceType('xls'); + expect(mediaType).toEqual('excel'); + mediaType = determineResourceType('xlsx'); + expect(mediaType).toEqual('excel'); + }); it('should always return file extension in lowercase', () => { const file = { @@ -72,5 +82,63 @@ describe('FileUtils', () => { expect(getFilenameFromContentDispositionHeader('attachment; filename*="filename.jpg"')).toBe('filename.jpg'); expect(getFilenameFromContentDispositionHeader('attachment')).toBe(''); }); + + describe('detectCSVDelimiter', () => { + it('should detect comma as delimiter', () => { + const input = 'a,b,c'; + expect(detectCSVDelimiter(input)).toBe(','); + }); + + it('should detect semicolon as delimiter', () => { + const input = 'a;b;c'; + expect(detectCSVDelimiter(input)).toBe(';'); + }); + + it('should detect pipe as delimiter', () => { + const input = 'a|b|c'; + expect(detectCSVDelimiter(input)).toBe('|'); + }); + + it('should detect tab as delimiter', () => { + const input = 'a\tb\tc'; + expect(detectCSVDelimiter(input)).toBe('\t'); + }); + + it('should default to comma if no delimiter is found', () => { + const input = 'abc'; + expect(detectCSVDelimiter(input)).toBe(','); + }); + }); + + describe('parseCSVToArray', () => { + it('should parse CSV with comma delimiter', () => { + const input = 'a,b,c\n1,2,3'; + const expectedOutput = [['a', 'b', 'c'], ['1', '2', '3']]; + expect(parseCSVToArray(input)).toEqual(expectedOutput); + }); + + it('should parse CSV with semicolon delimiter', () => { + const input = 'a;b;c\n1;2;3'; + const expectedOutput = [['a', 'b', 'c'], ['1', '2', '3']]; + expect(parseCSVToArray(input)).toEqual(expectedOutput); + }); + + it('should parse CSV with pipe delimiter', () => { + const input = 'a|b|c\n1|2|3'; + const expectedOutput = [['a', 'b', 'c'], ['1', '2', '3']]; + expect(parseCSVToArray(input)).toEqual(expectedOutput); + }); + + it('should parse CSV with tab delimiter', () => { + const input = 'a\tb\tc\n1\t2\t3'; + const expectedOutput = [['a', 'b', 'c'], ['1', '2', '3']]; + expect(parseCSVToArray(input)).toEqual(expectedOutput); + }); + + it('should return empty array for empty input', () => { + const input = ''; + expect(parseCSVToArray(input)).toEqual([]); + }); + }); }); diff --git a/geonode_mapstore_client/client/package.json b/geonode_mapstore_client/client/package.json index c1bb45fa85..85cd6f936f 100644 --- a/geonode_mapstore_client/client/package.json +++ b/geonode_mapstore_client/client/package.json @@ -38,7 +38,8 @@ "mapstore": "file:MapStore2", "react-helmet": "6.1.0", "react-intl": "2.3.0", - "react-router-dom": "4.1.1" + "react-router-dom": "4.1.1", + "xlsx": "0.18.5" }, "mapstore": { "output": "dist", diff --git a/geonode_mapstore_client/client/themes/geonode/less/_media-viewer.less b/geonode_mapstore_client/client/themes/geonode/less/_media-viewer.less index 03a10653a0..39c96d950b 100644 --- a/geonode_mapstore_client/client/themes/geonode/less/_media-viewer.less +++ b/geonode_mapstore_client/client/themes/geonode/less/_media-viewer.less @@ -6,6 +6,12 @@ .gn-media-scene-3d-info-bg { .background-color-var(@theme-vars[main-variant-bg]); } + .gn-csv-viewer { + .background-color-var(@theme-vars[main-variant-bg]); + .csv-container { + .background-color-var(@theme-vars[main-bg]); + } + } } // ************** @@ -15,10 +21,9 @@ .gn-media-viewer { top: 0; left: 0; - width: calc(100% - 2rem); - height: calc(100% - 2rem); + width: 100%; + height: 100%; position: absolute; - margin: 1rem; .ms-media { position: relative; @@ -42,7 +47,11 @@ height: auto; } - .pdf-loader { + .ms-media, .gn-pdf-viewer { + padding: 1rem; + } + + .pdf-loader, .csv-loader { position: absolute; top: 0; left: 0; @@ -57,18 +66,47 @@ .gn-main-event-text{ width: 50vw; } -} -.gn-media-viewer .pdf-loader { - position: 'absolute'; - top: 0; - left: 0; - width: '100%'; - height: '100%'; - background-color: 'rgba(255, 255, 255, 0.8)'; - z-index: 2; - display: 'flex'; - align-items: 'center'; - justify-content: 'center' + .gn-csv-viewer { + padding: 0 20em; + height: 100%; + .csv-container { + padding: 1.5em; + padding-bottom: 0; + display: flex; + flex-direction: column; + gap: 12px; + overflow-wrap: break-word; + height: 100%; + .title { + margin: 0; + font-weight: 500; + font-size: 1.5em; + } + .grid-container { + height: 100%; + min-height: 350px; + .empty-data { + display: flex; + justify-content: center; + font-size: 1.2em; + } + } + } + // make the content of cell copyable + .react-grid-Cell { + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + user-select: text; + } + @media screen and (max-width: 768px) { + padding: 0; + overflow-y: auto; + .csv-container { + height: auto; + } + } + } } .gn-media-scene-3d { diff --git a/geonode_mapstore_client/static/mapstore/gn-translations/data.de-DE.json b/geonode_mapstore_client/static/mapstore/gn-translations/data.de-DE.json index 07eb1bbdaa..dd320f54f9 100644 --- a/geonode_mapstore_client/static/mapstore/gn-translations/data.de-DE.json +++ b/geonode_mapstore_client/static/mapstore/gn-translations/data.de-DE.json @@ -454,7 +454,8 @@ "metadataNotFound": "Für die ausgewählte Ressource wurden keine Metadaten gefunden", "filterMetadata": "Nach Namen filtern...", "noMetadataFound": "Keine Metadaten gefunden...", - "metadataGroupTitle": "Allgemein" + "metadataGroupTitle": "Allgemein", + "noGridData": "Keine Daten zum Anzeigen" } } } diff --git a/geonode_mapstore_client/static/mapstore/gn-translations/data.en-US.json b/geonode_mapstore_client/static/mapstore/gn-translations/data.en-US.json index 86f5d597e1..fc52f8cc26 100644 --- a/geonode_mapstore_client/static/mapstore/gn-translations/data.en-US.json +++ b/geonode_mapstore_client/static/mapstore/gn-translations/data.en-US.json @@ -454,7 +454,8 @@ "metadataNotFound": "Metadata not found for the selected resource", "filterMetadata": "Filter by name...", "noMetadataFound": "No metadata found...", - "metadataGroupTitle": "General" + "metadataGroupTitle": "General", + "noGridData": "No data to display" } } } diff --git a/geonode_mapstore_client/static/mapstore/gn-translations/data.es-ES.json b/geonode_mapstore_client/static/mapstore/gn-translations/data.es-ES.json index 555256ef4b..038e9dca91 100644 --- a/geonode_mapstore_client/static/mapstore/gn-translations/data.es-ES.json +++ b/geonode_mapstore_client/static/mapstore/gn-translations/data.es-ES.json @@ -453,7 +453,8 @@ "metadataNotFound": "No se encontraron metadatos para el recurso seleccionado", "filterMetadata": "Filtrar por nombre...", "noMetadataFound": "No se encontraron metadatos...", - "metadataGroupTitle": "General" + "metadataGroupTitle": "General", + "noGridData": "No hay datos para mostrar" } } } diff --git a/geonode_mapstore_client/static/mapstore/gn-translations/data.fi-FI.json b/geonode_mapstore_client/static/mapstore/gn-translations/data.fi-FI.json index a2eb3dd074..5594751d5f 100644 --- a/geonode_mapstore_client/static/mapstore/gn-translations/data.fi-FI.json +++ b/geonode_mapstore_client/static/mapstore/gn-translations/data.fi-FI.json @@ -423,7 +423,8 @@ "metadataNotFound": "Metadata not found for the selected resource", "filterMetadata": "Filter by name...", "noMetadataFound": "No metadata found...", - "metadataGroupTitle": "General" + "metadataGroupTitle": "General", + "noGridData": "No data to display" } } } diff --git a/geonode_mapstore_client/static/mapstore/gn-translations/data.fr-FR.json b/geonode_mapstore_client/static/mapstore/gn-translations/data.fr-FR.json index 04438d60fe..75ac0c240e 100644 --- a/geonode_mapstore_client/static/mapstore/gn-translations/data.fr-FR.json +++ b/geonode_mapstore_client/static/mapstore/gn-translations/data.fr-FR.json @@ -454,7 +454,8 @@ "metadataNotFound": "Les métadonnées ne sont pas trouvées pour la ressource sélectionnée", "filterMetadata": "Filtrer par nom...", "noMetadataFound": "Aucune métadonnée trouvée...", - "metadataGroupTitle": "Général" + "metadataGroupTitle": "Général", + "noGridData": "Aucune donnée à afficher" } } } diff --git a/geonode_mapstore_client/static/mapstore/gn-translations/data.hr-HR.json b/geonode_mapstore_client/static/mapstore/gn-translations/data.hr-HR.json index 20e96445e3..fa59e8f9e2 100644 --- a/geonode_mapstore_client/static/mapstore/gn-translations/data.hr-HR.json +++ b/geonode_mapstore_client/static/mapstore/gn-translations/data.hr-HR.json @@ -423,7 +423,8 @@ "metadataNotFound": "Metadata not found for the selected resource", "filterMetadata": "Filter by name...", "noMetadataFound": "No metadata found...", - "metadataGroupTitle": "General" + "metadataGroupTitle": "General", + "noGridData": "No data to display" } } } diff --git a/geonode_mapstore_client/static/mapstore/gn-translations/data.it-IT.json b/geonode_mapstore_client/static/mapstore/gn-translations/data.it-IT.json index c8c43446af..a073eb3ac3 100644 --- a/geonode_mapstore_client/static/mapstore/gn-translations/data.it-IT.json +++ b/geonode_mapstore_client/static/mapstore/gn-translations/data.it-IT.json @@ -456,7 +456,8 @@ "metadataNotFound": "Metadati non disponibili per la risorsa selezionata", "filterMetadata": "Filtra per nome...", "noMetadataFound": "Nessun metadato trovato...", - "metadataGroupTitle": "Generale" + "metadataGroupTitle": "Generale", + "noGridData": "Nessun dato da visualizzare" } } } diff --git a/geonode_mapstore_client/static/mapstore/gn-translations/data.nl-NL.json b/geonode_mapstore_client/static/mapstore/gn-translations/data.nl-NL.json index f7b50c648f..b6fb8ab4d3 100644 --- a/geonode_mapstore_client/static/mapstore/gn-translations/data.nl-NL.json +++ b/geonode_mapstore_client/static/mapstore/gn-translations/data.nl-NL.json @@ -423,7 +423,8 @@ "metadataNotFound": "Metadata not found for the selected resource", "filterMetadata": "Filter by name...", "noMetadataFound": "No metadata found...", - "metadataGroupTitle": "General" + "metadataGroupTitle": "General", + "noGridData": "No data to display" } } } diff --git a/geonode_mapstore_client/static/mapstore/gn-translations/data.pt-PT.json b/geonode_mapstore_client/static/mapstore/gn-translations/data.pt-PT.json index 2030ac5d35..34f109ffd0 100644 --- a/geonode_mapstore_client/static/mapstore/gn-translations/data.pt-PT.json +++ b/geonode_mapstore_client/static/mapstore/gn-translations/data.pt-PT.json @@ -423,7 +423,8 @@ "metadataNotFound": "Metadata not found for the selected resource", "filterMetadata": "Filter by name...", "noMetadataFound": "No metadata found...", - "metadataGroupTitle": "General" + "metadataGroupTitle": "General", + "noGridData": "No data to display" } } } diff --git a/geonode_mapstore_client/static/mapstore/gn-translations/data.sk-SK.json b/geonode_mapstore_client/static/mapstore/gn-translations/data.sk-SK.json index f439c67556..2c9ec62847 100644 --- a/geonode_mapstore_client/static/mapstore/gn-translations/data.sk-SK.json +++ b/geonode_mapstore_client/static/mapstore/gn-translations/data.sk-SK.json @@ -423,7 +423,8 @@ "metadataNotFound": "Metadata not found for the selected resource", "filterMetadata": "Filter by name...", "noMetadataFound": "No metadata found...", - "metadataGroupTitle": "General" + "metadataGroupTitle": "General", + "noGridData": "No data to display" } } } diff --git a/geonode_mapstore_client/static/mapstore/gn-translations/data.sv-SE.json b/geonode_mapstore_client/static/mapstore/gn-translations/data.sv-SE.json index 77bf4bb121..a2c3845d7a 100644 --- a/geonode_mapstore_client/static/mapstore/gn-translations/data.sv-SE.json +++ b/geonode_mapstore_client/static/mapstore/gn-translations/data.sv-SE.json @@ -424,7 +424,8 @@ "metadataNotFound": "Metadata not found for the selected resource", "filterMetadata": "Filter by name...", "noMetadataFound": "No metadata found...", - "metadataGroupTitle": "General" + "metadataGroupTitle": "General", + "noGridData": "No data to display" } } } diff --git a/geonode_mapstore_client/static/mapstore/gn-translations/data.vi-VN.json b/geonode_mapstore_client/static/mapstore/gn-translations/data.vi-VN.json index 9681bd4373..a4aeb8ee22 100644 --- a/geonode_mapstore_client/static/mapstore/gn-translations/data.vi-VN.json +++ b/geonode_mapstore_client/static/mapstore/gn-translations/data.vi-VN.json @@ -423,7 +423,8 @@ "metadataNotFound": "Metadata not found for the selected resource", "filterMetadata": "Filter by name...", "noMetadataFound": "No metadata found...", - "metadataGroupTitle": "General" + "metadataGroupTitle": "General", + "noGridData": "No data to display" } } } diff --git a/geonode_mapstore_client/static/mapstore/gn-translations/data.zh-ZH.json b/geonode_mapstore_client/static/mapstore/gn-translations/data.zh-ZH.json index 58bad15726..0e27bf91a4 100644 --- a/geonode_mapstore_client/static/mapstore/gn-translations/data.zh-ZH.json +++ b/geonode_mapstore_client/static/mapstore/gn-translations/data.zh-ZH.json @@ -423,7 +423,8 @@ "metadataNotFound": "Metadata not found for the selected resource", "filterMetadata": "Filter by name...", "noMetadataFound": "No metadata found...", - "metadataGroupTitle": "General" + "metadataGroupTitle": "General", + "noGridData": "No data to display" } } }