Skip to content

Commit b2c463c

Browse files
#1949: Implement spreadsheet document viewer (#1952)
Co-authored-by: allyoucanmap <[email protected]>
1 parent 580be45 commit b2c463c

File tree

19 files changed

+279
-31
lines changed

19 files changed

+279
-31
lines changed

geonode_mapstore_client/client/js/components/MediaViewer/Media.jsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import React, { Suspense, lazy } from 'react';
1010
import MediaComponent from '@mapstore/framework/components/geostory/media';
1111
import PdfViewer from '@js/components/MediaViewer/PdfViewer';
12+
import SpreadsheetViewer from '@js/components/MediaViewer/SpreadsheetViewer';
1213
import { determineResourceType } from '@js/utils/FileUtils';
1314
import Loader from '@mapstore/framework/components/misc/Loader';
1415
import MainEventView from '@js/components/MainEventView';
@@ -30,6 +31,7 @@ const mediaMap = {
3031
gltf: Scene3DViewer,
3132
ifc: Scene3DViewer,
3233
audio: MediaComponent,
34+
excel: SpreadsheetViewer,
3335
unsupported: UnsupportedViewer
3436
};
3537

@@ -73,6 +75,8 @@ const Media = ({ resource, ...props }) => {
7375
url={resource ? metadataPreviewUrl(resource) : ''}
7476
isExternalSource={isDocumentExternalSource(resource)}
7577
bboxPolygon={resource?.ll_bbox_polygon}
78+
title={resource.title}
79+
extension={resource.extension}
7680
/>
7781
</Suspense>);
7882
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/*
2+
* Copyright 2025, GeoSolutions Sas.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import React, { useEffect, useState, Suspense, lazy } from "react";
10+
11+
import Loader from "@mapstore/framework/components/misc/Loader";
12+
import Message from "@mapstore/framework/components/I18N/Message";
13+
14+
import MetadataPreview from "@js/components/MetadataPreview/MetadataPreview";
15+
import { parseCSVToArray } from "@js/utils/FileUtils";
16+
const AdaptiveGrid = lazy(() => import("@mapstore/framework/components/misc/AdaptiveGrid"));
17+
18+
const VirtualizedGrid = ({data}) => {
19+
let [columns, ...rows] = data ?? [];
20+
columns = columns?.map((column, index) => ({ key: index, name: column, resizable: true })) ?? [];
21+
const rowGetter = rowNumber => rows?.[rowNumber];
22+
return (
23+
<div className="grid-container">
24+
<Suspense fallback={null}>
25+
<AdaptiveGrid
26+
columns={columns}
27+
rowGetter={rowGetter}
28+
rowsCount={rows?.length ?? 0}
29+
emptyRowsView={() => <span className="empty-data"><Message msgId="gnviewer.noGridData"/></span>}
30+
minColumnWidth={100}
31+
/>
32+
</Suspense>
33+
</div>
34+
);
35+
};
36+
37+
export const SpreadsheetViewer = ({extension, title, description, src, url}) => {
38+
const [data, setData] = useState([]);
39+
const [loading, setLoading] = useState(false);
40+
const [error, setError] = useState(null);
41+
42+
useEffect(() => {
43+
if (src) {
44+
setLoading(true);
45+
fetch(src)
46+
.then(response => extension === "csv"
47+
? response.text()
48+
: response.arrayBuffer()
49+
)
50+
.then((res) => {
51+
let response = res;
52+
if (extension !== "csv") {
53+
import('xlsx').then(({ read, utils }) => {
54+
const workbook = read(response, { type: "array" });
55+
56+
// Convert first sheet to CSV
57+
const sheetName = workbook.SheetNames[0];
58+
const worksheet = workbook.Sheets[sheetName];
59+
response = utils.sheet_to_csv(worksheet);
60+
setData(parseCSVToArray(response));
61+
}).catch((e) => {
62+
console.error("Failed to load xlsx module", e);
63+
});
64+
} else {
65+
setData(parseCSVToArray(response));
66+
}
67+
}).catch(() => {
68+
setError(true);
69+
}).finally(()=> {
70+
setLoading(false);
71+
});
72+
}
73+
}, [src]);
74+
75+
if (loading) {
76+
return (
77+
<div className="csv-loader">
78+
<Loader size={70}/>
79+
</div>
80+
);
81+
}
82+
83+
if (error) {
84+
return (
85+
<MetadataPreview url={url}/>
86+
);
87+
}
88+
89+
return data?.length > 0 ? (
90+
<div className="gn-csv-viewer">
91+
<div className="csv-container">
92+
<span className="title">{title}</span>
93+
<span className="description">{description}</span>
94+
<VirtualizedGrid data={data}/>
95+
</div>
96+
</div>
97+
) : null;
98+
};
99+
100+
export default SpreadsheetViewer;

geonode_mapstore_client/client/js/utils/FileUtils.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export const videoExtensions = ['mp4', 'mpg', 'avi', 'm4v', 'mp2', '3gp', 'flv',
3939
export const audioExtensions = ['mp3', 'wav', 'ogg'];
4040
export const gltfExtensions = ['glb', 'gltf'];
4141
export const ifcExtensions = ['ifc'];
42+
export const spreedsheetExtensions = ['csv', 'xls', 'xlsx'];
4243

4344
/**
4445
* check if a resource extension is supported for display in the media viewer
@@ -53,6 +54,7 @@ export const determineResourceType = extension => {
5354
if (ifcExtensions.includes(extension)) return 'ifc';
5455
if (ifcExtensions.includes(extension)) return 'ifc';
5556
if (audioExtensions.includes(extension)) return 'video';
57+
if (spreedsheetExtensions.includes(extension)) return 'excel';
5658
return 'unsupported';
5759
};
5860

@@ -109,3 +111,25 @@ export const getFilenameFromContentDispositionHeader = (contentDisposition) => {
109111
}
110112
return '';
111113
};
114+
115+
/**
116+
* Identify the delimiter used in a CSV string
117+
* Based on https://github.com/Inist-CNRS/node-csv-string
118+
* @param {string} input
119+
* @returns {string} delimiter
120+
*/
121+
export const detectCSVDelimiter = (input) => {
122+
const separators = [',', ';', '|', '\t'];
123+
const idx = separators
124+
.map((separator) => input.indexOf(separator))
125+
.reduce((prev, cur) =>
126+
prev === -1 || (cur !== -1 && cur < prev) ? cur : prev
127+
);
128+
return input[idx] || ',';
129+
};
130+
131+
export const parseCSVToArray = (response) => {
132+
if (isEmpty(response)) return [];
133+
const delimiter = detectCSVDelimiter(response);
134+
return response?.split('\n')?.map(row => row?.split(delimiter)) ?? [];
135+
};

geonode_mapstore_client/client/js/utils/__tests__/FileUtils-test.js

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import expect from 'expect';
22
import {
3+
detectCSVDelimiter,
34
determineResourceType,
45
getFileNameAndExtensionFromUrl,
56
getFileNameParts,
6-
getFilenameFromContentDispositionHeader
7+
getFilenameFromContentDispositionHeader,
8+
parseCSVToArray
79
} from '@js/utils/FileUtils';
810

911
describe('FileUtils', () => {
@@ -31,6 +33,14 @@ describe('FileUtils', () => {
3133
const mediaType = determineResourceType('mp3');
3234
expect(mediaType).toEqual('video');
3335
});
36+
it('should return excel if extension is a supported spreadsheet format', () => {
37+
let mediaType = determineResourceType('csv');
38+
expect(mediaType).toEqual('excel');
39+
mediaType = determineResourceType('xls');
40+
expect(mediaType).toEqual('excel');
41+
mediaType = determineResourceType('xlsx');
42+
expect(mediaType).toEqual('excel');
43+
});
3444

3545
it('should always return file extension in lowercase', () => {
3646
const file = {
@@ -72,5 +82,63 @@ describe('FileUtils', () => {
7282
expect(getFilenameFromContentDispositionHeader('attachment; filename*="filename.jpg"')).toBe('filename.jpg');
7383
expect(getFilenameFromContentDispositionHeader('attachment')).toBe('');
7484
});
85+
86+
describe('detectCSVDelimiter', () => {
87+
it('should detect comma as delimiter', () => {
88+
const input = 'a,b,c';
89+
expect(detectCSVDelimiter(input)).toBe(',');
90+
});
91+
92+
it('should detect semicolon as delimiter', () => {
93+
const input = 'a;b;c';
94+
expect(detectCSVDelimiter(input)).toBe(';');
95+
});
96+
97+
it('should detect pipe as delimiter', () => {
98+
const input = 'a|b|c';
99+
expect(detectCSVDelimiter(input)).toBe('|');
100+
});
101+
102+
it('should detect tab as delimiter', () => {
103+
const input = 'a\tb\tc';
104+
expect(detectCSVDelimiter(input)).toBe('\t');
105+
});
106+
107+
it('should default to comma if no delimiter is found', () => {
108+
const input = 'abc';
109+
expect(detectCSVDelimiter(input)).toBe(',');
110+
});
111+
});
112+
113+
describe('parseCSVToArray', () => {
114+
it('should parse CSV with comma delimiter', () => {
115+
const input = 'a,b,c\n1,2,3';
116+
const expectedOutput = [['a', 'b', 'c'], ['1', '2', '3']];
117+
expect(parseCSVToArray(input)).toEqual(expectedOutput);
118+
});
119+
120+
it('should parse CSV with semicolon delimiter', () => {
121+
const input = 'a;b;c\n1;2;3';
122+
const expectedOutput = [['a', 'b', 'c'], ['1', '2', '3']];
123+
expect(parseCSVToArray(input)).toEqual(expectedOutput);
124+
});
125+
126+
it('should parse CSV with pipe delimiter', () => {
127+
const input = 'a|b|c\n1|2|3';
128+
const expectedOutput = [['a', 'b', 'c'], ['1', '2', '3']];
129+
expect(parseCSVToArray(input)).toEqual(expectedOutput);
130+
});
131+
132+
it('should parse CSV with tab delimiter', () => {
133+
const input = 'a\tb\tc\n1\t2\t3';
134+
const expectedOutput = [['a', 'b', 'c'], ['1', '2', '3']];
135+
expect(parseCSVToArray(input)).toEqual(expectedOutput);
136+
});
137+
138+
it('should return empty array for empty input', () => {
139+
const input = '';
140+
expect(parseCSVToArray(input)).toEqual([]);
141+
});
142+
});
75143
});
76144

geonode_mapstore_client/client/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@
3838
"mapstore": "file:MapStore2",
3939
"react-helmet": "6.1.0",
4040
"react-intl": "2.3.0",
41-
"react-router-dom": "4.1.1"
41+
"react-router-dom": "4.1.1",
42+
"xlsx": "0.18.5"
4243
},
4344
"mapstore": {
4445
"output": "dist",

geonode_mapstore_client/client/themes/geonode/less/_media-viewer.less

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66
.gn-media-scene-3d-info-bg {
77
.background-color-var(@theme-vars[main-variant-bg]);
88
}
9+
.gn-csv-viewer {
10+
.background-color-var(@theme-vars[main-variant-bg]);
11+
.csv-container {
12+
.background-color-var(@theme-vars[main-bg]);
13+
}
14+
}
915
}
1016

1117
// **************
@@ -15,10 +21,9 @@
1521
.gn-media-viewer {
1622
top: 0;
1723
left: 0;
18-
width: calc(100% - 2rem);
19-
height: calc(100% - 2rem);
24+
width: 100%;
25+
height: 100%;
2026
position: absolute;
21-
margin: 1rem;
2227

2328
.ms-media {
2429
position: relative;
@@ -42,7 +47,11 @@
4247
height: auto;
4348
}
4449

45-
.pdf-loader {
50+
.ms-media, .gn-pdf-viewer {
51+
padding: 1rem;
52+
}
53+
54+
.pdf-loader, .csv-loader {
4655
position: absolute;
4756
top: 0;
4857
left: 0;
@@ -57,18 +66,47 @@
5766
.gn-main-event-text{
5867
width: 50vw;
5968
}
60-
}
61-
.gn-media-viewer .pdf-loader {
62-
position: 'absolute';
63-
top: 0;
64-
left: 0;
65-
width: '100%';
66-
height: '100%';
67-
background-color: 'rgba(255, 255, 255, 0.8)';
68-
z-index: 2;
69-
display: 'flex';
70-
align-items: 'center';
71-
justify-content: 'center'
69+
.gn-csv-viewer {
70+
padding: 0 20em;
71+
height: 100%;
72+
.csv-container {
73+
padding: 1.5em;
74+
padding-bottom: 0;
75+
display: flex;
76+
flex-direction: column;
77+
gap: 12px;
78+
overflow-wrap: break-word;
79+
height: 100%;
80+
.title {
81+
margin: 0;
82+
font-weight: 500;
83+
font-size: 1.5em;
84+
}
85+
.grid-container {
86+
height: 100%;
87+
min-height: 350px;
88+
.empty-data {
89+
display: flex;
90+
justify-content: center;
91+
font-size: 1.2em;
92+
}
93+
}
94+
}
95+
// make the content of cell copyable
96+
.react-grid-Cell {
97+
-webkit-user-select: text;
98+
-moz-user-select: text;
99+
-ms-user-select: text;
100+
user-select: text;
101+
}
102+
@media screen and (max-width: 768px) {
103+
padding: 0;
104+
overflow-y: auto;
105+
.csv-container {
106+
height: auto;
107+
}
108+
}
109+
}
72110
}
73111

74112
.gn-media-scene-3d {

geonode_mapstore_client/static/mapstore/gn-translations/data.de-DE.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -476,7 +476,8 @@
476476
"metadataNotFound": "Für die ausgewählte Ressource wurden keine Metadaten gefunden",
477477
"filterMetadata": "Nach Namen filtern...",
478478
"noMetadataFound": "Keine Metadaten gefunden...",
479-
"metadataGroupTitle": "Allgemein"
479+
"metadataGroupTitle": "Allgemein",
480+
"noGridData": "Keine Daten zum Anzeigen"
480481
}
481482
}
482483
}

geonode_mapstore_client/static/mapstore/gn-translations/data.en-US.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -476,7 +476,8 @@
476476
"metadataNotFound": "Metadata not found for the selected resource",
477477
"filterMetadata": "Filter by name...",
478478
"noMetadataFound": "No metadata found...",
479-
"metadataGroupTitle": "General"
479+
"metadataGroupTitle": "General",
480+
"noGridData": "No data to display"
480481
}
481482
}
482483
}

geonode_mapstore_client/static/mapstore/gn-translations/data.es-ES.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,8 @@
475475
"metadataNotFound": "No se encontraron metadatos para el recurso seleccionado",
476476
"filterMetadata": "Filtrar por nombre...",
477477
"noMetadataFound": "No se encontraron metadatos...",
478-
"metadataGroupTitle": "General"
478+
"metadataGroupTitle": "General",
479+
"noGridData": "No hay datos para mostrar"
479480
}
480481
}
481482
}

0 commit comments

Comments
 (0)