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
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Button } from '@gnomad/ui'
import { cnvTypeLabels } from './copyNumberVariantTypes'
import { CopyNumberVariant } from '../CopyNumberVariantPage/CopyNumberVariantPage'
import { logButtonClick } from '../analytics'
import { exportTableToCsv } from '../exportTableToCsv'

const columns = [
{
Expand Down Expand Up @@ -42,47 +43,6 @@ const columns = [
},
]

const exportVariantsToCsv = (variants: CopyNumberVariant[], baseFileName: any) => {
const headerRow = columns.map((c) => c.label)

const csv = `${headerRow}\r\n${variants
.map((variant: CopyNumberVariant) =>
columns
.map((c) => c.getValue(variant))
.map((val) =>
val.includes(',') || val.includes('"') || val.includes("'")
? `"${val.replace('"', '""')}"`
: val
)
.join(',')
)
.join('\r\n')}\r\n`

const date = new Date()
const timestamp = `${date.getFullYear()}_${(date.getMonth() + 1)
.toString()
.padStart(2, '0')}_${date.getDate().toString().padStart(2, '0')}_${date
.getHours()
.toString()
.padStart(2, '0')}_${date.getMinutes().toString().padStart(2, '0')}_${date
.getSeconds()
.toString()
.padStart(2, '0')}`

const blob = new Blob([csv], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.setAttribute('href', url)
link.setAttribute('download', `${baseFileName.replace(/\s+/g, '_')}_${timestamp}.csv`)
// @ts-expect-error TS(2551) FIXME: Property 'onClick' does not exist on type 'HTMLAnc... Remove this comment to see the full error message
link.onClick = () => {
URL.revokeObjectURL(url)
link.remove()
}
document.body.appendChild(link)
link.click()
}

type ExportCopyNumberVariantsButtonProps = {
exportFileName: string
variants: any[]
Expand All @@ -96,7 +56,7 @@ const ExportCopyNumberVariantsButton = ({
<Button
{...rest}
onClick={() => {
exportVariantsToCsv(variants, exportFileName)
exportTableToCsv(variants, columns, exportFileName)
logButtonClick('Exported copy number variants to CSV')
}}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { FLAGS_CONFIG } from '../VariantList/VariantFlag'
import { getLabelForConsequenceTerm } from '../vepConsequences'

import { logButtonClick } from '../analytics'
import { exportTableToCsv } from '../exportTableToCsv'

const BASE_COLUMNS = [
{
Expand Down Expand Up @@ -64,7 +65,7 @@ const BASE_COLUMNS = [
},
]

const exportVariantsToCsv = (variants: any, baseFileName: any, includeGene: any) => {
const getColumns = (includeGene: any) => {
const columns = [...BASE_COLUMNS]
if (includeGene) {
columns.splice(2, 0, {
Expand All @@ -73,44 +74,7 @@ const exportVariantsToCsv = (variants: any, baseFileName: any, includeGene: any)
})
}

const headerRow = columns.map((c) => c.label)

const csv = `${headerRow}\r\n${variants
.map((variant: any) =>
columns
.map((c) => c.getValue(variant))
.map((val) =>
val.includes(',') || val.includes('"') || val.includes("'")
? `"${val.replace('"', '""')}"`
: val
)
.join(',')
)
.join('\r\n')}\r\n`

const date = new Date()
const timestamp = `${date.getFullYear()}_${(date.getMonth() + 1)
.toString()
.padStart(2, '0')}_${date.getDate().toString().padStart(2, '0')}_${date
.getHours()
.toString()
.padStart(2, '0')}_${date.getMinutes().toString().padStart(2, '0')}_${date
.getSeconds()
.toString()
.padStart(2, '0')}`

const blob = new Blob([csv], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.setAttribute('href', url)
link.setAttribute('download', `${baseFileName.replace(/\s+/g, '_')}_${timestamp}.csv`)
// @ts-expect-error TS(2551) FIXME: Property 'onClick' does not exist on type 'HTMLAnc... Remove this comment to see the full error message
link.onClick = () => {
URL.revokeObjectURL(url)
link.remove()
}
document.body.appendChild(link)
link.click()
return columns
}

type OwnProps = {
Expand All @@ -133,7 +97,7 @@ const ExportMitochondrialVariantsButton = ({
<Button
{...rest}
onClick={() => {
exportVariantsToCsv(variants, exportFileName, includeGene)
exportTableToCsv(variants, getColumns(includeGene), exportFileName)
logButtonClick('Exported mitochondrial variants to CSV')
}}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { svConsequenceLabels } from './structuralVariantConsequences'
import { StructuralVariant } from '../StructuralVariantPage/StructuralVariantPage'
import { svTypeLabels } from './structuralVariantTypes'
import { logButtonClick } from '../analytics'
import { exportTableToCsv } from '../exportTableToCsv'

const columns = [
{
Expand Down Expand Up @@ -71,47 +72,6 @@ const columns = [
},
]

const exportVariantsToCsv = (variants: any, baseFileName: any) => {
const headerRow = columns.map((c) => c.label)

const csv = `${headerRow}\r\n${variants
.map((variant: any) =>
columns
.map((c) => c.getValue(variant))
.map((val) =>
val.includes(',') || val.includes('"') || val.includes("'")
? `"${val.replace('"', '""')}"`
: val
)
.join(',')
)
.join('\r\n')}\r\n`

const date = new Date()
const timestamp = `${date.getFullYear()}_${(date.getMonth() + 1)
.toString()
.padStart(2, '0')}_${date.getDate().toString().padStart(2, '0')}_${date
.getHours()
.toString()
.padStart(2, '0')}_${date.getMinutes().toString().padStart(2, '0')}_${date
.getSeconds()
.toString()
.padStart(2, '0')}`

const blob = new Blob([csv], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.setAttribute('href', url)
link.setAttribute('download', `${baseFileName.replace(/\s+/g, '_')}_${timestamp}.csv`)
// @ts-expect-error TS(2551) FIXME: Property 'onClick' does not exist on type 'HTMLAnc... Remove this comment to see the full error message
link.onClick = () => {
URL.revokeObjectURL(url)
link.remove()
}
document.body.appendChild(link)
link.click()
}

type ExportStructuralVariantsButtonProps = {
exportFileName: string
variants: StructuralVariant[]
Expand All @@ -125,7 +85,7 @@ const ExportStructuralVariantsButton = ({
<Button
{...rest}
onClick={() => {
exportVariantsToCsv(variants, exportFileName)
exportTableToCsv(variants, columns, exportFileName)
logButtonClick('Exported structural variants to CSV')
}}
>
Expand Down
45 changes: 3 additions & 42 deletions browser/src/VariantList/ExportVariantsButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,11 @@ import {
import { DatasetId, isV2, isV4, hasJointFrequencyData } from '@gnomad/dataset-metadata/metadata'

import { logButtonClick } from '../analytics'
import { CsvColumn, exportTableToCsv } from '../exportTableToCsv'

type ColumnGetter = (variant: VariantTableVariant) => string

export type Column = {
label: string
getValue: ColumnGetter
}
export type Column = CsvColumn<VariantTableVariant>

type Property = 'ac' | 'an' | 'ac_hemi' | 'ac_hom'

Expand Down Expand Up @@ -354,44 +352,7 @@ const exportVariantsToCsv = (

const columns = DEFAULT_COLUMNS.concat(versionSpecificColumns, populationColumns)

const headerRow = columns.map((c) => c.label)

const csv = `${headerRow}\r\n${variants
.map((variant) =>
columns
.map((c) => c.getValue(variant))
.map((val) =>
val.includes(',') || val.includes('"') || val.includes("'")
? `"${val.replace('"', '""')}"`
: val
)
.join(',')
)
.join('\r\n')}\r\n`

const date = new Date()
const timestamp = `${date.getFullYear()}_${(date.getMonth() + 1)
.toString()
.padStart(2, '0')}_${date.getDate().toString().padStart(2, '0')}_${date
.getHours()
.toString()
.padStart(2, '0')}_${date.getMinutes().toString().padStart(2, '0')}_${date
.getSeconds()
.toString()
.padStart(2, '0')}`

const blob = new Blob([csv], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.setAttribute('href', url)
link.setAttribute('download', `${baseFileName.replace(/\s+/g, '_')}_${timestamp}.csv`)
// @ts-expect-error TS(2551) FIXME: Property 'onClick' does not exist on type 'HTMLAnc... Remove this comment to see the full error message
link.onClick = () => {
URL.revokeObjectURL(url)
link.remove()
}
document.body.appendChild(link)
link.click()
exportTableToCsv(variants, columns, baseFileName)
}

export type VariantTableVariant = {
Expand Down
63 changes: 63 additions & 0 deletions browser/src/exportTableToCsv.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, test } from '@jest/globals'

import {
escapeCsvValue,
formatCsvExportTimestamp,
getCsvExportFileName,
serializeTableToCsv,
} from './exportTableToCsv'

describe('serializeTableToCsv', () => {
test('preserves column order and CRLF row separators', () => {
const csv = serializeTableToCsv(
[
{ id: 'first', value: '1' },
{ id: 'second', value: '2' },
],
[
{ label: 'ID', getValue: (row) => row.id },
{ label: 'Value', getValue: (row) => row.value },
]
)

expect(csv).toBe('ID,Value\r\nfirst,1\r\nsecond,2\r\n')
})

test('preserves the existing empty-row output', () => {
const csv = serializeTableToCsv([], [{ label: 'Variant ID', getValue: () => '' }])

expect(csv).toBe('Variant ID\r\n\r\n')
})

test('preserves the existing newline-only value behavior', () => {
const csv = serializeTableToCsv(
[{ value: 'first line\nsecond line' }],
[{ label: 'Value', getValue: (row) => row.value }]
)

expect(csv).toBe('Value\r\nfirst line\nsecond line\r\n')
})
})

describe('escapeCsvValue', () => {
test('quotes values containing commas, double quotes, or single quotes', () => {
expect(escapeCsvValue('a,b')).toBe('"a,b"')
expect(escapeCsvValue('has "quote"')).toBe('"has ""quote""')
expect(escapeCsvValue("has 'apostrophe'")).toBe('"has \'apostrophe\'"')
})

test('leaves values without legacy trigger characters unquoted', () => {
expect(escapeCsvValue('plain value')).toBe('plain value')
})
})

describe('CSV export filenames', () => {
test('formats timestamps and replaces whitespace in the base filename', () => {
const date = new Date(2026, 4, 13, 7, 8, 9)

expect(formatCsvExportTimestamp(date)).toBe('2026_05_13_07_08_09')
expect(getCsvExportFileName('gene page variants', date)).toBe(
'gene_page_variants_2026_05_13_07_08_09.csv'
)
})
})
53 changes: 53 additions & 0 deletions browser/src/exportTableToCsv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
export type CsvColumn<Row> = {
label: string
getValue: (row: Row) => string
}

export const escapeCsvValue = (value: string) =>
value.includes(',') || value.includes('"') || value.includes("'")
? `"${value.replace('"', '""')}"`
: value

export const serializeTableToCsv = <Row>(rows: Row[], columns: CsvColumn<Row>[]) => {
const headerRow = columns.map((column) => column.label).join(',')

return `${headerRow}\r\n${rows
.map((row) =>
columns
.map((column) => column.getValue(row))
.map(escapeCsvValue)
.join(',')
)
.join('\r\n')}\r\n`
}

export const formatCsvExportTimestamp = (date: Date) =>
`${date.getFullYear()}_${(date.getMonth() + 1).toString().padStart(2, '0')}_${date
.getDate()
.toString()
.padStart(2, '0')}_${date.getHours().toString().padStart(2, '0')}_${date
.getMinutes()
.toString()
.padStart(2, '0')}_${date.getSeconds().toString().padStart(2, '0')}`

export const getCsvExportFileName = (baseFileName: string, date = new Date()) =>
`${baseFileName.replace(/\s+/g, '_')}_${formatCsvExportTimestamp(date)}.csv`

export const exportTableToCsv = <Row>(
rows: Row[],
columns: CsvColumn<Row>[],
baseFileName: string
) => {
const csv = serializeTableToCsv(rows, columns)
const blob = new Blob([csv], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.setAttribute('href', url)
link.setAttribute('download', getCsvExportFileName(baseFileName))
link.onclick = () => {
URL.revokeObjectURL(url)
link.remove()
}
document.body.appendChild(link)
link.click()
}