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"
}
}
}