diff --git a/client/src/components/FrameLayout/FrameBodyToolbar.js b/client/src/components/FrameLayout/FrameBodyToolbar.js index 96fd1612..d9a21007 100644 --- a/client/src/components/FrameLayout/FrameBodyToolbar.js +++ b/client/src/components/FrameLayout/FrameBodyToolbar.js @@ -9,6 +9,18 @@ import Tabs from 'react-bootstrap/Tabs' import { TAB_GEO, TAB_JSON, TAB_QUERY, TAB_VISUAL } from 'actions/frames' import GraphIcon from 'components/GraphIcon' +import { downloadCSV } from 'lib/csvExport' + +const ACTION_DOWNLOAD_CSV = 'download-csv' + +const getCsvFilename = () => { + const pad = (value) => String(value).padStart(2, '0') + const now = new Date() + const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad( + now.getDate(), + )}-${pad(now.getHours())}${pad(now.getMinutes())}` + return `ratel-results-${timestamp}.csv` +} export default function FrameBodyToolbar({ frame, @@ -20,6 +32,14 @@ export default function FrameBodyToolbar({ const isError = tabResult.error || (tabResult.response && tabResult.response.error) + const onSelectTab = (tab) => { + if (tab === ACTION_DOWNLOAD_CSV) { + downloadCSV(tabResult.response.data, getCsvFilename()) + return + } + setActiveTab(tab) + } + const toolbarBtn = (id, icon, label) => ( { + if (!isQueryFrame || !tabResult.response?.data) { + return null + } + return toolbarBtn( + ACTION_DOWNLOAD_CSV, + , + 'Download CSV', + ) + } + return ( {visualTab()} {toolbarBtn(TAB_JSON, , 'JSON')} {toolbarBtn(TAB_QUERY, , 'Request')} {toolbarBtn(TAB_GEO, , 'Geo')} + {downloadCsvTab()} ) } diff --git a/client/src/lib/csvExport.js b/client/src/lib/csvExport.js new file mode 100644 index 00000000..19748bac --- /dev/null +++ b/client/src/lib/csvExport.js @@ -0,0 +1,109 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +const isPlainObject = (value) => + value !== null && typeof value === 'object' && !Array.isArray(value) + +const flattenValue = (row, key, value) => { + if (value === null || value === undefined) { + row[key] = '' + return + } + if (Array.isArray(value)) { + if (value.length === 0) { + row[key] = '' + } else if (value.some(isPlainObject)) { + // Arrays of objects are kept as JSON, not exploded into rows. + row[key] = JSON.stringify(value) + } else { + row[key] = value.join('; ') + } + return + } + if (isPlainObject(value)) { + Object.entries(value).forEach(([childKey, childValue]) => + flattenValue(row, `${key}.${childKey}`, childValue), + ) + return + } + row[key] = value +} + +// Flattens a Dgraph query response ({ block: [obj, ...], ... }) into an +// array of flat row objects with dot-notation keys for nested objects. +export function flattenRows(responseData) { + if (!isPlainObject(responseData)) { + return [] + } + const blocks = Object.entries(responseData).filter(([, value]) => + Array.isArray(value), + ) + const multiBlock = blocks.length > 1 + + const rows = [] + blocks.forEach(([blockName, items]) => { + items.forEach((item) => { + if (!isPlainObject(item)) { + return + } + const row = {} + if (multiBlock) { + row.__block = blockName + } + Object.entries(item).forEach(([key, value]) => + flattenValue(row, key, value), + ) + rows.push(row) + }) + }) + return rows +} + +const escapeField = (value) => { + const str = value === null || value === undefined ? '' : String(value) + if (/[",\n\r]/.test(str)) { + return `"${str.replace(/"/g, '""')}"` + } + return str +} + +// Serializes flat row objects into an RFC-4180 CSV string. The header is +// the union of all row keys, in first-seen order. +export function toCSV(rows) { + if (!Array.isArray(rows) || rows.length === 0) { + return '' + } + const headers = [] + rows.forEach((row) => + Object.keys(row).forEach((key) => { + if (!headers.includes(key)) { + headers.push(key) + } + }), + ) + const lines = [headers.map(escapeField).join(',')] + rows.forEach((row) => + lines.push(headers.map((header) => escapeField(row[header])).join(',')), + ) + return lines.join('\r\n') +} + +// Builds a CSV from a Dgraph query response and triggers a browser +// download. No-op when there is nothing to export. +export function downloadCSV(responseData, filename) { + const csv = toCSV(flattenRows(responseData)) + if (!csv) { + return + } + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) +} diff --git a/client/src/lib/csvExport.test.js b/client/src/lib/csvExport.test.js new file mode 100644 index 00000000..985502cd --- /dev/null +++ b/client/src/lib/csvExport.test.js @@ -0,0 +1,143 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { downloadCSV, flattenRows, toCSV } from './csvExport' + +describe('flattenRows', () => { + it('flattens simple flat rows from a single block', () => { + const data = { + q: [ + { uid: '0x1', name: 'Alice' }, + { uid: '0x2', name: 'Bob' }, + ], + } + expect(flattenRows(data)).toEqual([ + { uid: '0x1', name: 'Alice' }, + { uid: '0x2', name: 'Bob' }, + ]) + }) + + it('flattens nested objects with dot-notation keys', () => { + const data = { + q: [{ uid: '0x1', address: { city: 'Pune', geo: { lat: 18.5 } } }], + } + expect(flattenRows(data)).toEqual([ + { uid: '0x1', 'address.city': 'Pune', 'address.geo.lat': 18.5 }, + ]) + }) + + it('joins arrays of scalars with "; "', () => { + const data = { + q: [{ uid: '0x1', tags: ['a', 'b', 'c'], scores: [1, 2] }], + } + expect(flattenRows(data)).toEqual([ + { uid: '0x1', tags: 'a; b; c', scores: '1; 2' }, + ]) + }) + + it('JSON-stringifies arrays of objects without exploding rows', () => { + const friends = [ + { uid: '0x2', name: 'Bob' }, + { uid: '0x3', name: 'Carol' }, + ] + const data = { q: [{ uid: '0x1', name: 'Alice', friend: friends }] } + expect(flattenRows(data)).toEqual([ + { uid: '0x1', name: 'Alice', friend: JSON.stringify(friends) }, + ]) + }) + + it('adds a __block column when there is more than one top-level block', () => { + const data = { + people: [{ name: 'Alice' }], + cities: [{ name: 'Pune' }], + } + expect(flattenRows(data)).toEqual([ + { __block: 'people', name: 'Alice' }, + { __block: 'cities', name: 'Pune' }, + ]) + }) + + it('omits the __block column for a single block', () => { + const data = { q: [{ name: 'Alice' }] } + expect(flattenRows(data)[0].__block).toBeUndefined() + }) + + it('converts null and empty-array values to empty strings', () => { + const data = { q: [{ name: null, tags: [] }] } + expect(flattenRows(data)).toEqual([{ name: '', tags: '' }]) + }) + + it('returns an empty array for empty or missing data', () => { + expect(flattenRows(undefined)).toEqual([]) + expect(flattenRows(null)).toEqual([]) + expect(flattenRows({})).toEqual([]) + expect(flattenRows({ q: [] })).toEqual([]) + }) +}) + +describe('toCSV', () => { + it('uses the union of keys in first-seen order as the header', () => { + const rows = [ + { a: 1, b: 2 }, + { b: 3, c: 4 }, + ] + expect(toCSV(rows)).toBe('a,b,c\r\n1,2,\r\n,3,4') + }) + + it('quotes fields containing commas', () => { + expect(toCSV([{ name: 'Doe, Jane' }])).toBe('name\r\n"Doe, Jane"') + }) + + it('quotes and doubles embedded quotes', () => { + expect(toCSV([{ name: 'say "hi"' }])).toBe('name\r\n"say ""hi"""') + }) + + it('quotes fields containing newlines', () => { + expect(toCSV([{ note: 'line1\nline2' }])).toBe('note\r\n"line1\nline2"') + }) + + it('quotes header names that need escaping', () => { + expect(toCSV([{ 'a,b': 1 }])).toBe('"a,b"\r\n1') + }) + + it('returns an empty string for empty or missing rows', () => { + expect(toCSV([])).toBe('') + expect(toCSV(undefined)).toBe('') + }) +}) + +describe('downloadCSV', () => { + let createObjectURL + let revokeObjectURL + + beforeEach(() => { + createObjectURL = jest.fn(() => 'blob:fake-url') + revokeObjectURL = jest.fn() + URL.createObjectURL = createObjectURL + URL.revokeObjectURL = revokeObjectURL + }) + + it('creates, clicks and cleans up a download link', () => { + const click = jest + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => {}) + + downloadCSV({ q: [{ name: 'Alice' }] }, 'ratel-results-test.csv') + + expect(createObjectURL).toHaveBeenCalledTimes(1) + expect(click).toHaveBeenCalledTimes(1) + expect(revokeObjectURL).toHaveBeenCalledWith('blob:fake-url') + expect(document.querySelector('a[download]')).toBeNull() + + click.mockRestore() + }) + + it('is a no-op for empty or missing data', () => { + downloadCSV(undefined, 'x.csv') + downloadCSV({}, 'x.csv') + downloadCSV({ q: [] }, 'x.csv') + expect(createObjectURL).not.toHaveBeenCalled() + }) +})