Skip to content
Open
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
34 changes: 33 additions & 1 deletion client/src/components/FrameLayout/FrameBodyToolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) => (
<Tab
eventKey={id}
Expand Down Expand Up @@ -50,17 +70,29 @@ export default function FrameBodyToolbar({
)
}

const downloadCsvTab = () => {
if (!isQueryFrame || !tabResult.response?.data) {
return null
}
return toolbarBtn(
ACTION_DOWNLOAD_CSV,
<i className='icon fas fa-download' />,
'Download CSV',
)
}

return (
<Tabs
className='toolbar'
id='frame-tabs'
activeKey={activeTab}
onSelect={setActiveTab}
onSelect={onSelectTab}
>
{visualTab()}
{toolbarBtn(TAB_JSON, <i className='icon fa fa-code' />, 'JSON')}
{toolbarBtn(TAB_QUERY, <i className='icon fas fa-terminal' />, 'Request')}
{toolbarBtn(TAB_GEO, <i className='icon fas fa-globe-americas' />, 'Geo')}
{downloadCsvTab()}
</Tabs>
)
}
109 changes: 109 additions & 0 deletions client/src/lib/csvExport.js
Original file line number Diff line number Diff line change
@@ -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)
}
143 changes: 143 additions & 0 deletions client/src/lib/csvExport.test.js
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading