From 799f67ea49c20455643a6c0f9716569370023ab4 Mon Sep 17 00:00:00 2001 From: msramalho <19508417+msramalho@users.noreply.github.com> Date: Wed, 6 Apr 2022 18:57:09 +0200 Subject: [PATCH] implements csv+json downloads --- config.js | 1 + package-lock.json | 85 ++++++++++++++++++----- package.json | 1 + src/common/constants.js | 1 + src/common/data/copy.json | 19 ++++- src/common/utilities.js | 13 ++++ src/components/Layout.js | 3 +- src/components/Toolbar.js | 51 ++++++++++---- src/components/controls/DownloadButton.js | 84 ++++++++++++++++++++++ src/components/controls/DownloadPanel.js | 21 ++++++ src/scss/toolbar.scss | 15 +++- src/store/initial.js | 6 ++ 12 files changed, 265 insertions(+), 35 deletions(-) create mode 100644 src/components/controls/DownloadButton.js create mode 100644 src/components/controls/DownloadPanel.js diff --git a/config.js b/config.js index c610c88..0ea2f6d 100644 --- a/config.js +++ b/config.js @@ -147,6 +147,7 @@ module.exports = { COLOR_BY_ASSOCIATION: true, USE_ASSOCIATIONS: true, USE_FULLSCREEN: true, + USE_DOWNLOAD: true, USE_SOURCES: true, USE_SPOTLIGHTS: false, USE_SHAPES: false, diff --git a/package-lock.json b/package-lock.json index ed221a1..ddc7d5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "jest-resolve": "26.6.0", "jest-watch-typeahead": "0.6.1", "joi": "^14.0.1", + "json2csv": "^5.0.7", "leaflet": "^1.0.3", "lint-staged": "^10.5.3", "marked": "^0.7.0", @@ -15261,6 +15262,31 @@ "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", "devOptional": true }, + "node_modules/json2csv": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/json2csv/-/json2csv-5.0.7.tgz", + "integrity": "sha512-YRZbUnyaJZLZUJSRi2G/MqahCyRv9n/ds+4oIetjDF3jWQA7AG7iSeKTiZiCNqtMZM7HDyt0e/W6lEnoGEmMGA==", + "dependencies": { + "commander": "^6.1.0", + "jsonparse": "^1.3.1", + "lodash.get": "^4.4.2" + }, + "bin": { + "json2csv": "bin/json2csv.js" + }, + "engines": { + "node": ">= 10", + "npm": ">= 6.13.0" + } + }, + "node_modules/json2csv/node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "engines": { + "node": ">= 6" + } + }, "node_modules/json3": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/json3/-/json3-3.3.3.tgz", @@ -15288,6 +15314,14 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=", + "engines": [ + "node >= 0.2.0" + ] + }, "node_modules/jsprim": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", @@ -15845,6 +15879,11 @@ "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=", "dev": true }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -24434,19 +24473,6 @@ "is-typedarray": "^1.0.0" } }, - "node_modules/typescript": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.2.tgz", - "integrity": "sha512-HM/hFigTBHZhLXshn9sN37H085+hQGeJHJ/X7LpBWLID/fbc2acUMfU+lGD98X81sKP+pFa9f0DZmCwB9GnbAg==", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, "node_modules/ua-parser-js": { "version": "0.7.31", "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.31.tgz", @@ -37962,6 +37988,23 @@ "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", "devOptional": true }, + "json2csv": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/json2csv/-/json2csv-5.0.7.tgz", + "integrity": "sha512-YRZbUnyaJZLZUJSRi2G/MqahCyRv9n/ds+4oIetjDF3jWQA7AG7iSeKTiZiCNqtMZM7HDyt0e/W6lEnoGEmMGA==", + "requires": { + "commander": "^6.1.0", + "jsonparse": "^1.3.1", + "lodash.get": "^4.4.2" + }, + "dependencies": { + "commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==" + } + } + }, "json3": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/json3/-/json3-3.3.3.tgz", @@ -37981,6 +38024,11 @@ "universalify": "^2.0.0" } }, + "jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=" + }, "jsprim": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", @@ -38407,6 +38455,11 @@ "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=", "dev": true }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=" + }, "lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -45128,12 +45181,6 @@ "is-typedarray": "^1.0.0" } }, - "typescript": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.2.tgz", - "integrity": "sha512-HM/hFigTBHZhLXshn9sN37H085+hQGeJHJ/X7LpBWLID/fbc2acUMfU+lGD98X81sKP+pFa9f0DZmCwB9GnbAg==", - "peer": true - }, "ua-parser-js": { "version": "0.7.31", "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.31.tgz", diff --git a/package.json b/package.json index cf51440..b1592be 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "jest-resolve": "26.6.0", "jest-watch-typeahead": "0.6.1", "joi": "^14.0.1", + "json2csv": "^5.0.7", "leaflet": "^1.0.3", "lint-staged": "^10.5.3", "marked": "^0.7.0", diff --git a/src/common/constants.js b/src/common/constants.js index 0ad7ec5..d70ef22 100644 --- a/src/common/constants.js +++ b/src/common/constants.js @@ -11,6 +11,7 @@ export const DEFAULT_TAB_ICONS = { NARRATIVE: "timeline", FILTER: "filter_list", SHAPE: "change_history", + DOWNLOAD: "download" }; export const AVAILABLE_SHAPES = { diff --git a/src/common/data/copy.json b/src/common/data/copy.json index 6847064..781d3e1 100644 --- a/src/common/data/copy.json +++ b/src/common/data/copy.json @@ -159,7 +159,24 @@ "explore_by_shapes__title": "Explore events by shape breakdown", "explore_by_shape__description": "Shapes map to a given type of event that appears on the timeline.

Select the shape marker to toggle this type of event on / off", "fullscreen_enter": "Fullscreen", - "fullscreen_exit": "Exit Fullscreen" + "fullscreen_exit": "Exit Fullscreen", + "download": { + "button": "Download", + "panel":{ + "title": "Download events", + "description": "Export the most recent available events in different formats.", + "formats": { + "csv": { + "label": "CSV", + "description": "CSV file where sources and filters are concatenated into a single column due to data structure limitations." + }, + "json": { + "label": "JSON", + "description": "JSON file where each event is a structured object containing nested arrays of sources and filters." + } + } + } + } }, "timeline": { "labels_title": "Testimonies", diff --git a/src/common/utilities.js b/src/common/utilities.js index bdcb50a..0a1e775 100644 --- a/src/common/utilities.js +++ b/src/common/utilities.js @@ -576,4 +576,17 @@ export function getFilterIdx( else return 0; } +export function downloadAsFile(filename, content) { + let element = document.createElement('a'); + element.setAttribute('href', `data:application/octet-stream;charset=utf-8,${encodeURIComponent(content)}`); + element.setAttribute('download', filename); + + element.style.display = 'none'; + document.body.appendChild(element); + + element.click(); + + document.body.removeChild(element); +} + export const isEmptyString = (s) => s.length === 0; diff --git a/src/components/Layout.js b/src/components/Layout.js index 0f3e358..376d585 100644 --- a/src/components/Layout.js +++ b/src/components/Layout.js @@ -301,12 +301,13 @@ class Dashboard extends React.Component { overflowY: "scroll", textAlign: "justify", }; - + return (
{checkMobile ? null : ( diff --git a/src/components/Toolbar.js b/src/components/Toolbar.js index 6f9db38..42ac804 100644 --- a/src/components/Toolbar.js +++ b/src/components/Toolbar.js @@ -23,6 +23,7 @@ import { } from "../common/utilities.js"; import { ToolbarButton } from "./controls/atoms/ToolbarButton"; import { FullscreenToggle } from "./controls/FullScreenToggle"; +import DownloadPanel from "./controls/DownloadPanel"; class Toolbar extends React.Component { constructor(props) { @@ -180,6 +181,21 @@ class Toolbar extends React.Component { } } + renderToolbarDownloadPanel() { + const { panels } = this.props.toolbarCopy; + + return ( + + + + ); + } + renderToolbarTab(_selected, label, iconKey, key) { return ( ); } @@ -267,7 +284,8 @@ class Toolbar extends React.Component { features.USE_CATEGORIES, numCategoryPanels || 0 ); - const shapesIdx = filtersIdx + 1; + const shapesIdx = filtersIdx + features.USE_SHAPES; + const downloadIdx = shapesIdx + features.USE_DOWNLOAD; return (
@@ -278,27 +296,34 @@ class Toolbar extends React.Component { {narrativesExist ? this.renderToolbarTab( - narrativesIdx, - panels.narratives.label, - panels.narratives.icon - ) + narrativesIdx, + panels.narratives.label, + panels.narratives.icon + ) : null} {features.USE_CATEGORIES ? this.renderToolbarCategoryTabs(categoryIdxs) : null} {features.USE_ASSOCIATIONS ? this.renderToolbarTab( - filtersIdx, - panels.filters.label, - panels.filters.icon - ) + filtersIdx, + panels.filters.label, + panels.filters.icon + ) : null} {features.USE_SHAPES ? this.renderToolbarTab( - shapesIdx, - panels.shapes.label, - panels.shapes.icon - ) + shapesIdx, + panels.shapes.label, + panels.shapes.icon + ) + : null} + {features.USE_DOWNLOAD + ? this.renderToolbarTab( + downloadIdx, + panels.download.label, + panels.download.icon + ) : null} {features.USE_FULLSCREEN && ( diff --git a/src/components/controls/DownloadButton.js b/src/components/controls/DownloadButton.js new file mode 100644 index 0000000..27ba045 --- /dev/null +++ b/src/components/controls/DownloadButton.js @@ -0,0 +1,84 @@ +import React from "react"; +import copy from "../../common/data/copy.json"; +import { parse } from 'json2csv' +import { downloadAsFile } from "../../common/utilities" + +export class DownloadButton extends React.Component { + onDownload(format, domain) { + let filename = `ukr-civharm-${this.datetimeToDateString(new Date())}`; + if (format === "csv") { + let outputData = this.getCsvData(domain) + downloadAsFile(`${filename}.csv`, outputData); + } else if (format === "json") { + let outputData = this.getJsonData(domain) + downloadAsFile(`${filename}.json`, outputData); + } + } + getCsvData(domain) { + const { events, sources } = domain; + const exportEvents = events.map(e => { + return { + id: e.civId, + date: this.datetimeToDateString(e.datetime), + latitude: e.latitude, + longitude: e.longitude, + location: e.location, + description: e.description, + sources: e.sources.map(s => sources[s].paths[0]).join(","), + associations: e.associations.map(a => a.filter_paths.join("=")).join(",") + } + }) + return parse(exportEvents, { flatten: true }) + } + getJsonData(domain) { + const { events, sources } = domain; + const exportEvents = events.map(e => { + return { + id: e.civId, + date: this.datetimeToDateString(e.datetime), + latitude: e.latitude, + longitude: e.longitude, + location: e.location, + description: e.description, + sources: e.sources.map(id => { + const s = sources[id] + return { + id, + path: s.paths[0], + description: s.description + } + }), + filters: e.associations.map(a => { + return { + key: a.filter_paths[0], + value: a.filter_paths[1] + } + }) + } + }); + return JSON.stringify(exportEvents); + } + datetimeToDateString(datetime) { + try { + return datetime.toISOString().split("T")[0] + } catch (_) { } + return ""; + } + render() { + const { language, domain, format } = this.props; + const textByFormat = copy[language].toolbar.download.panel.formats[format] + return ( +
+ this.onDownload(format, domain)} + > + {"download"} + {textByFormat.label} + + {textByFormat.description} +
+ ); + } +} diff --git a/src/components/controls/DownloadPanel.js b/src/components/controls/DownloadPanel.js new file mode 100644 index 0000000..57aab67 --- /dev/null +++ b/src/components/controls/DownloadPanel.js @@ -0,0 +1,21 @@ +import React from "react"; +import { DownloadButton } from "./DownloadButton"; + +const DownloadPanel = ({ + language, + title, + description, + domain +}) => { + return ( +
+

{title}

+

{description}

+
+ + +
+ ); +}; + +export default DownloadPanel; diff --git a/src/scss/toolbar.scss b/src/scss/toolbar.scss index 912c8ba..5dca37e 100644 --- a/src/scss/toolbar.scss +++ b/src/scss/toolbar.scss @@ -186,7 +186,20 @@ } } - .toolbar-tab { + .download-row{ + display: flex; + flex-direction: row; + } + .download-button{ + flex: 1 1 auto; + } + .download-description{ + flex: 1 5 auto; + text-align: justify; + margin: auto; + } + + .toolbar-tab, .download-button { display: flex; align-items: center; justify-content: center; diff --git a/src/store/initial.js b/src/store/initial.js index 36bb5cf..3ef375a 100644 --- a/src/store/initial.js +++ b/src/store/initial.js @@ -139,6 +139,12 @@ const initial = { title: copy[language].toolbar.explore_by_shape__title, description: copy[language].toolbar.explore_by_shape__description, }, + download: { + icon: DEFAULT_TAB_ICONS.DOWNLOAD, + label: copy[language].toolbar.download.button, + title: copy[language].toolbar.download.panel.title, + description: copy[language].toolbar.download.panel.description, + }, }, }, loading: false,