Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#1949: Implement spreadsheet document viewer #1952

Merged
merged 3 commits into from
Feb 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -30,6 +31,7 @@ const mediaMap = {
gltf: Scene3DViewer,
ifc: Scene3DViewer,
audio: MediaComponent,
excel: SpreadsheetViewer,
unsupported: UnsupportedViewer
};

Expand Down Expand Up @@ -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}
/>
</Suspense>);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div className="grid-container">
<Suspense fallback={null}>
<AdaptiveGrid
columns={columns}
rowGetter={rowGetter}
rowsCount={rows?.length ?? 0}
emptyRowsView={() => <span className="empty-data"><Message msgId="gnviewer.noGridData"/></span>}
minColumnWidth={100}
/>
</Suspense>
</div>
);
};

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 (
<div className="csv-loader">
<Loader size={70}/>
</div>
);
}

if (error) {
return (
<MetadataPreview url={url}/>
);
}

return data?.length > 0 ? (
<div className="gn-csv-viewer">
<div className="csv-container">
<span className="title">{title}</span>
<span className="description">{description}</span>
<VirtualizedGrid data={data}/>
</div>
</div>
) : null;
};

export default SpreadsheetViewer;
24 changes: 24 additions & 0 deletions geonode_mapstore_client/client/js/utils/FileUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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';
};

Expand Down Expand Up @@ -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)) ?? [];
};
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import expect from 'expect';
import {
detectCSVDelimiter,
determineResourceType,
getFileNameAndExtensionFromUrl,
getFileNameParts,
getFilenameFromContentDispositionHeader
getFilenameFromContentDispositionHeader,
parseCSVToArray
} from '@js/utils/FileUtils';

describe('FileUtils', () => {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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([]);
});
});
});

3 changes: 2 additions & 1 deletion geonode_mapstore_client/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
}
}

// **************
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Loading