Skip to content
Draft
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
81 changes: 81 additions & 0 deletions frontend/common/utils/__tests__/csv.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { extractIdentifiers, parseCsvText, toParsedCsv } from 'common/utils/csv'

describe('parseCsvText', () => {
const cases: [string, string, string[][]][] = [
['single column', 'a\nb\nc', [['a'], ['b'], ['c']]],
[
'multiple columns',
'id,email\n1,a@b.com',
[
['id', 'email'],
['1', 'a@b.com'],
],
],
['crlf line endings', 'a\r\nb\r\n', [['a'], ['b']]],
[
'quoted fields with commas and escaped quotes',
'"a,b","say ""hi"""\nc,d',
[
['a,b', 'say "hi"'],
['c', 'd'],
],
],
['blank lines dropped', 'a\n\n \nb', [['a'], ['b']]],
['empty input', '', []],
]

test.each(cases)('%s', (_, input, expected) => {
expect(parseCsvText(input)).toEqual(expected)
})
})

describe('toParsedCsv', () => {
const rawRows = [
['id', 'email'],
['1', 'a@b.com'],
]

test('with headers, first row becomes column names', () => {
expect(toParsedCsv(rawRows, true)).toEqual({
columns: ['id', 'email'],
rows: [['1', 'a@b.com']],
})
})

test('without headers, generates column_N names', () => {
expect(toParsedCsv(rawRows, false)).toEqual({
columns: ['column_1', 'column_2'],
rows: rawRows,
})
})

test('blank header cells fall back to column_N', () => {
expect(toParsedCsv([['id', ''], ['1']], true).columns).toEqual([
'id',
'column_2',
])
})

test('empty input yields no columns or rows', () => {
expect(toParsedCsv([], true)).toEqual({ columns: [], rows: [] })
})
})

describe('extractIdentifiers', () => {
test('trims values and counts empty and duplicate rows', () => {
const rows = [['a'], [' b '], [''], ['a'], [' '], ['b']]
expect(extractIdentifiers(rows, 0)).toEqual({
duplicateCount: 2,
emptyCount: 2,
identifiers: ['a', 'b'],
})
})

test('missing cells in short rows count as empty', () => {
expect(extractIdentifiers([['x', 'y'], ['z']], 1)).toEqual({
duplicateCount: 0,
emptyCount: 1,
identifiers: ['y'],
})
})
})
98 changes: 98 additions & 0 deletions frontend/common/utils/csv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
export type ParsedCsv = {
columns: string[]
rows: string[][]
}

export type ExtractedIdentifiers = {
duplicateCount: number
emptyCount: number
identifiers: string[]
}

export function parseCsvText(text: string): string[][] {
const rows: string[][] = []
let row: string[] = []
let field = ''
let inQuotes = false
for (let i = 0; i < text.length; i++) {
const char = text[i]
if (inQuotes) {
if (char === '"') {
if (text[i + 1] === '"') {
field += '"'
i++
} else {
inQuotes = false
}
} else {
field += char
}
} else if (char === '"') {
inQuotes = true
} else if (char === ',') {
row.push(field)
field = ''
} else if (char === '\n' || char === '\r') {
if (char === '\r' && text[i + 1] === '\n') {
i++
}
row.push(field)
rows.push(row)
row = []
field = ''
} else {
field += char
}
}
if (field !== '' || row.length) {
row.push(field)
rows.push(row)
}
return rows.filter((cells) => cells.some((cell) => cell.trim() !== ''))
}

export function toParsedCsv(
rawRows: string[][],
hasHeaders: boolean,
): ParsedCsv {
if (!rawRows.length) {
return { columns: [], rows: [] }
}
const columnCount = Math.max(...rawRows.map((cells) => cells.length))
if (hasHeaders) {
const [header, ...rows] = rawRows
return {
columns: Array.from(
{ length: columnCount },
(_, i) => header[i]?.trim() || `column_${i + 1}`,
),
rows,
}
}
return {
columns: Array.from({ length: columnCount }, (_, i) => `column_${i + 1}`),
rows: rawRows,
}
}

export function extractIdentifiers(
rows: string[][],
columnIndex: number,
): ExtractedIdentifiers {
const seen = new Set<string>()
const identifiers: string[] = []
let emptyCount = 0
let duplicateCount = 0
for (const cells of rows) {
const value = (cells[columnIndex] ?? '').trim()
if (!value) {
emptyCount++
} else if (seen.has(value)) {
duplicateCount++
} else {
seen.add(value)
identifiers.push(value)
}
}
return { duplicateCount, emptyCount, identifiers }
}
13 changes: 12 additions & 1 deletion frontend/e2e/helpers/e2e-helpers.playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,18 @@ export class E2EHelpers {
) {
await this.click(byId('show-create-segment-btn'));
const flagsmith = await getFlagsmith();
if (flagsmith.hasFeature('create_segment_with_external_sources')) {
const segmentSources = flagsmith.getValue(
'create_segment_with_external_sources',
{
fallback: null,
json: true,
},
);
if (
flagsmith.hasFeature('create_segment_with_external_sources') &&
Array.isArray(segmentSources) &&
segmentSources.some((source) => source?.visible !== false)
) {
await this.click(byId('create-segment-manually'));
}
await this.setText(byId('segmentID'), name);
Expand Down
5 changes: 5 additions & 0 deletions frontend/web/components/CsvUpload/CsvUpload.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.csv-upload {
.droparea {
border-color: var(--color-border-action);
}
}
81 changes: 81 additions & 0 deletions frontend/web/components/CsvUpload/CsvUpload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { FC, useCallback, useState } from 'react'
import { useDropzone } from 'react-dropzone'
import DropIcon from 'components/icons/DropIcon'
import Button from 'components/base/forms/Button'
import ErrorMessage from 'components/ErrorMessage'
import './CsvUpload.scss'

export type CsvUploadType = {
value: File | null
onChange: (file: File, text: string) => void
}

const CsvUpload: FC<CsvUploadType> = ({ onChange, value }) => {
const [error, setError] = useState('')

const onDrop = useCallback(
(acceptedFiles: File[]) => {
setError('')
const file = acceptedFiles[0]
if (!file) {
return
}
const reader = new FileReader()
reader.addEventListener('load', () => {
onChange(file, `${reader.result}`)
})
reader.addEventListener('error', () => {
setError('Error reading file')
})
reader.readAsText(file)
},
[onChange],
)

const { getInputProps, getRootProps } = useDropzone({
accept: {
'text/csv': ['.csv'],
},
multiple: false,
onDrop,
onDropRejected: () => {
setError('Please select a CSV file')
},
})

return (
<div className='csv-upload cursor-pointer'>
{value ? (
<Row>
<div {...getRootProps()}>
<input {...getInputProps()} />
<div className='flex-row droparea droparea--condensed text-center'>
<DropIcon width={24} height={24} />
<div className='ml-2'>
<strong className={'fs-small'}>{value.name}</strong>
</div>
<Button size='small' className={'ml-2'}>
Select File
</Button>
</div>
</div>
</Row>
) : (
<div {...getRootProps()}>
<input {...getInputProps()} />
<div className='droparea text-center'>
<DropIcon />
<div className='mb-2'>
<strong>Select a file or drag and drop here</strong>
</div>
<div className='text-muted fs-small mb-4'>CSV File</div>
<Button>Select File</Button>
</div>
</div>
)}
{!!error && <ErrorMessage error={error} />}
</div>
)
}

export default CsvUpload
1 change: 1 addition & 0 deletions frontend/web/components/CsvUpload/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './CsvUpload'
Loading
Loading