diff --git a/package-lock.json b/package-lock.json index db3ff42..fc1749f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4840,6 +4840,15 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-13.1.8.tgz", "integrity": "sha512-6XzyyNM9EKQW4HKuzbo/CkOIjn/evtCmsU+MUM1xDfJ+3/rNjBttM1NgN7AOQvN6tP1Sl1D1PIKMreTArnxM9A==" }, + "@types/papaparse": { + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.14.tgz", + "integrity": "sha512-LxJ4iEFcpqc6METwp9f6BV6VVc43m6MfH0VqFosHvrUgfXiFe6ww7R3itkOQ+TCK6Y+Iv/+RnnvtRZnkc5Kc9g==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -17043,6 +17052,11 @@ "integrity": "sha512-0DTvPVU3ed8+HNXOu5Bs+o//Mbdj9VNQMUOe9oKCwh8l0GNwpTDMKCWbRjgtD291AWnkAgkqA/LOnQS8AmS1tw==", "dev": true }, + "papaparse": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz", + "integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==" + }, "parallel-transform": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.1.0.tgz", diff --git a/package.json b/package.json index 6211625..e004781 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "@types/jest": "^25.1.0", "@types/js-base64": "^2.3.1", "@types/jsonwebtoken": "^8.3.7", + "@types/papaparse": "^5.3.14", "@types/react": "^16.9.19", "@types/react-dom": "^16.9.5", "@types/react-jsonschema-form": "^1.6.8", @@ -135,6 +136,7 @@ "jsonwebtoken": "^8.5.1", "jsqr": "^1.2.0", "lines-and-columns": "^1.1.6", + "papaparse": "^5.4.1", "qrcode-svg": "^1.1.0", "react": "^16.12.0", "react-ace": "^8.0.0", diff --git a/src/lara-app/components/runtime.module.scss b/src/lara-app/components/runtime.module.scss index 6122874..a4f81a5 100644 --- a/src/lara-app/components/runtime.module.scss +++ b/src/lara-app/components/runtime.module.scss @@ -76,10 +76,11 @@ flex-wrap: nowrap; flex-direction: row; justify-content: flex-end; + margin: 10px; + gap: 10px; .button { display: inline-block; - margin: 10px; border: 1px solid $ccTealDark2; background-color: #fff; color: $ccTealDark2; diff --git a/src/lara-app/components/runtime.tsx b/src/lara-app/components/runtime.tsx index a20d254..c80ae3b 100644 --- a/src/lara-app/components/runtime.tsx +++ b/src/lara-app/components/runtime.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useRef } from "react"; import * as firebase from "firebase/app"; import "firebase/firestore"; + import { Experiment } from "../../shared/components/experiment"; import { IExperiment, IExperimentData, IExperimentConfig } from "../../shared/experiment-types"; import { IRun } from "../../mobile-app/hooks/use-runs"; @@ -13,6 +14,7 @@ const QRCode = require("qrcode-svg"); import { generateDataset } from "../utils/generate-dataset"; import css from "./runtime.module.scss"; import { IJwtClaims } from "@concord-consortium/lara-plugin-api"; +import { downloadCSV } from "../utils/download-csv"; const UPDATE_QR_INTERVAL = 1000 * 60 * 60; // 60 minutes @@ -151,6 +153,13 @@ export const RuntimeComponent = ({ displayingCode.current = true; }; + const handleDownloadCSV = () => { + // not stuttering here... + if (experimentData?.experimentData) { + downloadCSV(experiment, experimentData); + } + }; + const toggleDisplayQr = () => setDisplayQrAndMaybeRegenerateQR(!displayQr); const handleSaveData = (data: IExperimentData) => { @@ -215,6 +224,7 @@ export const RuntimeComponent = ({ {reportOrPreviewMode ? undefined :
Import
+ {experimentData &&
Download CSV
}
}
", bar: ""}, + ] +}; + +const timeSeriesExperiment: IExperiment = { + version: "1.0.0", + metadata: { + uuid: "bfca7fb5-39ea-4238-8515-e469c44aa872", + name: "Time Series Experiment", + initials: "TS", + }, + schema: { + dataSchema: { + properties: { + experimentData: { + items: { + properties: { + timeSeries: { + title: "Results" + }, + label: { + title: "Label" + } + } + } + } + } + }, + formUiSchema: { + experimentData: { + "ui:dataTableOptions": { + sensorFields: ["timeSeries"] + } + } + } + } as any, + data: {} +}; +const timeSeriesData: IExperimentData = { + timestamp: 987654321, + experimentData: [ + {timeSeries: [{time: 0, value: 1}, {time: 1, value: 2}], label: "Label #1"}, + {timeSeries: [{time: 1, value: 3}, {time: 2, value: 4}], label: "Label #2"} + ] +}; + + +describe("download csv functions", () => { + + describe("getTimeKey()", () => { + it("works for integers", () => { + expect(getTimeKey(0)).toBe("0"); + }); + it("works for floats, up to 3 digits", () => { + expect(getTimeKey(0.1)).toBe("0.1"); + expect(getTimeKey(0.10)).toBe("0.1"); + expect(getTimeKey(0.100)).toBe("0.1"); + expect(getTimeKey(0.101)).toBe("0.101"); + expect(getTimeKey(0.1011)).toBe("0.101"); + expect(getTimeKey(0.1000000001)).toBe("0.1"); + }); + }); + + describe("sortNaturally()", () => { + it("sorts", () => { + expect(sortNaturally(["20", "2", "1", "100", "10", "20.1"])).toStrictEqual(["1", "2", "10", "20", "20.1", "100"]); + }); + }); + + describe("getFileName()", () => { + it("generates filename", () => { + expect(getFilename(tabularExperiment, tabularData)).toBe("tabular-experiment-123456789.csv"); + expect(getFilename(timeSeriesExperiment, timeSeriesData)).toBe("time-series-experiment-987654321.csv"); + }); + }); + + describe("getRows()", () => { + it("works for tabular data with function symbols", () => { + expect(getRows(tabularExperiment, tabularData)).toStrictEqual([ + {Foo: 1, Bar: 2}, + {Foo: 2, Bar: 3}, + {Foo: 1.5, Bar: 2.5}, + ]); + }); + + it("works for time series data", () => { + expect(getRows(timeSeriesExperiment, timeSeriesData)).toStrictEqual([ + { + "Time": "0", + "Row 1 Results": 1, + "Row 2 Results": "", + "Row 1 Label": "Label #1", + "Row 2 Label": "Label #2", + }, + { + "Time": "1", + "Row 1 Results": 2, + "Row 2 Results": 3, + "Row 1 Label": "Label #1", + "Row 2 Label": "Label #2", + }, + { + "Time": "2", + "Row 1 Results": "", + "Row 2 Results": 4, + "Row 1 Label": "Label #1", + "Row 2 Label": "Label #2", + }, + ]); + }); + }); + + describe("downloadCSV", () => { + it("downloads", () => { + const createObjectURL = jest.fn(); + const appendChild = jest.fn(); + const removeChild = jest.fn(); + + const saveUrl = window.URL; + const saveBody = document.body; + window.URL = { createObjectURL } as any; + document.body.appendChild = appendChild; + document.body.removeChild = removeChild; + + downloadCSV(tabularExperiment, tabularData); + + expect(createObjectURL).toBeCalledTimes(1); + expect(appendChild).toBeCalledTimes(1); + expect(removeChild).toBeCalledTimes(1); + + window.URL = saveUrl; + document.body = saveBody; + }); + }); +}); \ No newline at end of file diff --git a/src/lara-app/utils/download-csv.ts b/src/lara-app/utils/download-csv.ts new file mode 100644 index 0000000..f259868 --- /dev/null +++ b/src/lara-app/utils/download-csv.ts @@ -0,0 +1,113 @@ +import {unparse} from "papaparse"; + +import { IDataTableRow, IDataTableTimeData } from "../../shared/components/data-table-field"; +import { IExperiment, IExperimentData } from "../../shared/experiment-types"; +import { isFunctionSymbol, handleSpecialValue } from "../../shared/utils/handle-special-value"; + +type TimeValues = Record; +type OtherValues = Record; +type TimeSeriesRow = {timeValues: TimeValues, otherValues: OtherValues}; + +export const getTimeKey = (time: number) => time.toFixed(3).replace(/\.?0+$/, ""); + +const naturalCollator = Intl.Collator(undefined, { numeric: true }); +export const sortNaturally = (arr: string[]): string[] => { + arr.sort((a, b) => naturalCollator.compare(a, b)); + return arr; +}; + +export const getRowValue = (key: string, rawRow: IDataTableRow, rawRows: IDataTableRow[]) => { + let value: any = rawRow[key] ?? ""; + if (isFunctionSymbol(value)) { + value = handleSpecialValue(value, key, rawRows); + if (value === undefined) { + value = ""; + } + } + return value; +}; + +export const getRows = (experiment: IExperiment, data: IExperimentData) => { + // get all columns + const properties = experiment.schema.dataSchema.properties.experimentData?.items?.properties ?? {}; + const titleMap = Object.keys(properties).reduce>((acc, key) => { + acc[key] = properties[key].title ?? key; + return acc; + }, {}); + + const rawRows: IDataTableRow[] = data.experimentData; + const isTimeSeries = (experiment.schema.formUiSchema?.experimentData?.["ui:dataTableOptions"]?.sensorFields || []).indexOf("timeSeries") !== -1; + + if (isTimeSeries) { + const nonTimeSeriesTitles = Object.keys(titleMap).filter(key => key !== "timeSeries"); + const timeKeys = new Set(); + const timeSeriesRows: TimeSeriesRow[] = []; + + rawRows.forEach(rawRow => { + const timeValues: TimeValues = {}; + const otherValues: OtherValues = {}; + + nonTimeSeriesTitles.forEach(key => { + otherValues[titleMap[key]] = getRowValue(key, rawRow, rawRows); + }); + + const timeSeries = (rawRow.timeSeries ?? []) as IDataTableTimeData[]; + timeSeries.forEach(({time, value}) => { + const timeKey = getTimeKey(time); + timeValues[timeKey] = value; + timeKeys.add(timeKey); + }); + + timeSeriesRows.push({timeValues, otherValues}); + }, []); + const sortedTimeKeys = sortNaturally(Array.from(timeKeys)); + + // unroll each time/value reading into its own row + const unrolledRows: Record[] = []; + const timeSeriesKey = titleMap.timeSeries ?? "Time Series Value"; + sortedTimeKeys.forEach(timeKey => { + const row: Record = {Time: timeKey}; + timeSeriesRows.forEach(({timeValues}, rowIndex) => { + row[`Row ${rowIndex + 1} ${timeSeriesKey}`] = timeValues[timeKey] ?? ""; + }); + timeSeriesRows.forEach(({otherValues}, rowIndex) => { + nonTimeSeriesTitles.forEach(key => { + const title = titleMap[key]; + row[`Row ${rowIndex + 1} ${title}`] = otherValues[title] ?? ""; + }); + }); + unrolledRows.push(row); + }); + + return unrolledRows; + } + + // create an empty row with all the experiment columns to ensure it in the same order + const emptyRow = Object.values(titleMap).reduce>((acc, value) => { + acc[value] = ""; + return acc; + }, {}); + + return rawRows.map(rawRow => { + return Object.keys(rawRow).reduce>((acc, key) => { + acc[titleMap[key] ?? key] = getRowValue(key, rawRow, rawRows); + return acc; + }, {...emptyRow}); + }); +}; + +export const getFilename = (experiment: IExperiment, data: IExperimentData) => { + return `${experiment.metadata.name.trim().toLowerCase().replace(/[^a-zA-Z]/g, "-")}-${data.timestamp}.csv`; +}; + +export const downloadCSV = (experiment: IExperiment, data: IExperimentData) => { + const rows = getRows(experiment, data); + const blob = new Blob([unparse(rows)], { type: 'text/csv' }); + const link = document.createElement('a'); + + link.href = URL.createObjectURL(blob); + link.download = getFilename(experiment, data); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +}; \ No newline at end of file