Skip to content

Commit 0102a0c

Browse files
authored
feat: create segment from CSV drawer (#8283)
1 parent 763938b commit 0102a0c

21 files changed

Lines changed: 757 additions & 30 deletions

File tree

frontend/common/theme/tokens.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@
9494
"action-active": { "cssVar": "--color-surface-action-active", "light": "#3919b7", "dark": "#4e25db" },
9595
"action-subtle": { "cssVar": "--color-surface-action-subtle", "light": "rgba(104, 55, 252, 0.08)", "dark": "rgba(255, 255, 255, 0.08)" },
9696
"action-muted": { "cssVar": "--color-surface-action-muted", "light": "rgba(104, 55, 252, 0.16)", "dark": "rgba(255, 255, 255, 0.16)" },
97+
"action-tint": { "cssVar": "--color-surface-action-tint", "light": "rgba(104, 55, 252, 0.12)", "dark": "rgba(144, 106, 246, 0.16)", "description": "Selected/highlighted surface that keeps its purple cast in dark mode, unlike the neutral dark alphas of action-subtle/action-muted." },
9798
"danger": { "cssVar": "--color-surface-danger", "light": "rgba(239, 77, 86, 0.08)", "dark": "oklch(from var(--red-500) 0.18 0.02 h)" },
9899
"success": { "cssVar": "--color-surface-success", "light": "rgba(39, 171, 149, 0.08)", "dark": "oklch(from var(--green-500) 0.18 0.02 h)" },
99100
"warning": { "cssVar": "--color-surface-warning", "light": "rgba(255, 159, 67, 0.08)", "dark": "oklch(from var(--orange-500) 0.18 0.02 h)" },

frontend/common/theme/tokens.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,8 @@ export const colorSurfaceActionMuted =
179179
'var(--color-surface-action-muted, rgba(104, 55, 252, 0.16))'
180180
export const colorSurfaceActionSubtle =
181181
'var(--color-surface-action-subtle, rgba(104, 55, 252, 0.08))'
182+
export const colorSurfaceActionTint =
183+
'var(--color-surface-action-tint, rgba(104, 55, 252, 0.12))'
182184
export const colorSurfaceActive =
183185
'var(--color-surface-active, rgba(0, 0, 0, 0.16))'
184186
export const colorSurfaceDanger =
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { extractIdentifiers, parseCsvText, toParsedCsv } from 'common/utils/csv'
2+
3+
describe('parseCsvText', () => {
4+
const cases: [string, string, string[][]][] = [
5+
['single column', 'a\nb\nc', [['a'], ['b'], ['c']]],
6+
[
7+
'multiple columns',
8+
'id,email\n1,a@b.com',
9+
[
10+
['id', 'email'],
11+
['1', 'a@b.com'],
12+
],
13+
],
14+
['crlf line endings', 'a\r\nb\r\n', [['a'], ['b']]],
15+
[
16+
'quoted fields with commas and escaped quotes',
17+
'"a,b","say ""hi"""\nc,d',
18+
[
19+
['a,b', 'say "hi"'],
20+
['c', 'd'],
21+
],
22+
],
23+
['blank lines dropped', 'a\n\n \nb', [['a'], ['b']]],
24+
['empty input', '', []],
25+
]
26+
27+
test.each(cases)('%s', (_, input, expected) => {
28+
expect(parseCsvText(input)).toEqual(expected)
29+
})
30+
})
31+
32+
describe('toParsedCsv', () => {
33+
const rawRows = [
34+
['id', 'email'],
35+
['1', 'a@b.com'],
36+
]
37+
38+
test('with headers, first row becomes column names', () => {
39+
expect(toParsedCsv(rawRows, true)).toEqual({
40+
columns: ['id', 'email'],
41+
rows: [['1', 'a@b.com']],
42+
})
43+
})
44+
45+
test('without headers, generates Column N names', () => {
46+
expect(toParsedCsv(rawRows, false)).toEqual({
47+
columns: ['Column 1', 'Column 2'],
48+
rows: rawRows,
49+
})
50+
})
51+
52+
test('blank header cells fall back to Column N', () => {
53+
expect(toParsedCsv([['id', ''], ['1']], true).columns).toEqual([
54+
'id',
55+
'Column 2',
56+
])
57+
})
58+
59+
test('empty input yields no columns or rows', () => {
60+
expect(toParsedCsv([], true)).toEqual({ columns: [], rows: [] })
61+
})
62+
})
63+
64+
describe('extractIdentifiers', () => {
65+
test('trims values and counts empty and duplicate rows', () => {
66+
const rows = [['a'], [' b '], [''], ['a'], [' '], ['b']]
67+
expect(extractIdentifiers(rows, 0)).toEqual({
68+
duplicateCount: 2,
69+
emptyCount: 2,
70+
identifiers: ['a', 'b'],
71+
})
72+
})
73+
74+
test('missing cells in short rows count as empty', () => {
75+
expect(extractIdentifiers([['x', 'y'], ['z']], 1)).toEqual({
76+
duplicateCount: 0,
77+
emptyCount: 1,
78+
identifiers: ['y'],
79+
})
80+
})
81+
})

frontend/common/utils/csv.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
export type ParsedCsv = {
2+
columns: string[]
3+
rows: string[][]
4+
}
5+
6+
export type ExtractedIdentifiers = {
7+
duplicateCount: number
8+
emptyCount: number
9+
identifiers: string[]
10+
}
11+
12+
export function parseCsvText(text: string): string[][] {
13+
const rows: string[][] = []
14+
let row: string[] = []
15+
let field = ''
16+
let inQuotes = false
17+
for (let i = 0; i < text.length; i++) {
18+
const char = text[i]
19+
if (inQuotes) {
20+
if (char === '"') {
21+
if (text[i + 1] === '"') {
22+
field += '"'
23+
i++
24+
} else {
25+
inQuotes = false
26+
}
27+
} else {
28+
field += char
29+
}
30+
} else if (char === '"') {
31+
inQuotes = true
32+
} else if (char === ',') {
33+
row.push(field)
34+
field = ''
35+
} else if (char === '\n' || char === '\r') {
36+
if (char === '\r' && text[i + 1] === '\n') {
37+
i++
38+
}
39+
row.push(field)
40+
rows.push(row)
41+
row = []
42+
field = ''
43+
} else {
44+
field += char
45+
}
46+
}
47+
if (field !== '' || row.length) {
48+
row.push(field)
49+
rows.push(row)
50+
}
51+
return rows.filter((cells) => cells.some((cell) => cell.trim() !== ''))
52+
}
53+
54+
export function toParsedCsv(
55+
rawRows: string[][],
56+
hasHeaders: boolean,
57+
): ParsedCsv {
58+
if (!rawRows.length) {
59+
return { columns: [], rows: [] }
60+
}
61+
const columnCount = Math.max(...rawRows.map((cells) => cells.length))
62+
if (hasHeaders) {
63+
const [header, ...rows] = rawRows
64+
return {
65+
columns: Array.from(
66+
{ length: columnCount },
67+
(_, i) => header[i]?.trim() || `Column ${i + 1}`,
68+
),
69+
rows,
70+
}
71+
}
72+
return {
73+
columns: Array.from({ length: columnCount }, (_, i) => `Column ${i + 1}`),
74+
rows: rawRows,
75+
}
76+
}
77+
78+
export function extractIdentifiers(
79+
rows: string[][],
80+
columnIndex: number,
81+
): ExtractedIdentifiers {
82+
const seen = new Set<string>()
83+
const identifiers: string[] = []
84+
let emptyCount = 0
85+
let duplicateCount = 0
86+
for (const cells of rows) {
87+
const value = (cells[columnIndex] ?? '').trim()
88+
if (!value) {
89+
emptyCount++
90+
} else if (seen.has(value)) {
91+
duplicateCount++
92+
} else {
93+
seen.add(value)
94+
identifiers.push(value)
95+
}
96+
}
97+
return { duplicateCount, emptyCount, identifiers }
98+
}

frontend/documentation/TokenReference.generated.stories.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,14 @@ export const AllTokens: StoryObj = {
117117
<code>oklch(from var(--purple-600) l c h / 0.16)</code>
118118
</td>
119119
</tr>
120+
<tr>
121+
<td>
122+
<code>--color-surface-action-tint</code>
123+
</td>
124+
<td>
125+
<code>oklch(from var(--purple-600) l c h / 0.12)</code>
126+
</td>
127+
</tr>
120128
<tr>
121129
<td>
122130
<code>--color-surface-danger</code>

frontend/e2e/helpers/e2e-helpers.playwright.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -538,7 +538,18 @@ export class E2EHelpers {
538538
) {
539539
await this.click(byId('show-create-segment-btn'));
540540
const flagsmith = await getFlagsmith();
541-
if (flagsmith.hasFeature('create_segment_with_external_sources')) {
541+
const segmentSources = flagsmith.getValue(
542+
'create_segment_with_external_sources',
543+
{
544+
fallback: null,
545+
json: true,
546+
},
547+
);
548+
if (
549+
flagsmith.hasFeature('create_segment_with_external_sources') &&
550+
Array.isArray(segmentSources) &&
551+
segmentSources.some((source) => source?.visible !== false)
552+
) {
542553
await this.click(byId('create-segment-manually'));
543554
}
544555
await this.setText(byId('segmentID'), name);
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
.csv-upload {
2+
&__droparea {
3+
padding: 32px;
4+
border: 1px dashed var(--color-border-action);
5+
}
6+
7+
&__file-card {
8+
border: 1px solid var(--color-border-default);
9+
}
10+
11+
&__file-icon {
12+
width: 36px;
13+
height: 36px;
14+
}
15+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { FC, useCallback, useState } from 'react'
2+
import { useDropzone } from 'react-dropzone'
3+
import { colorIconAction } from 'common/theme/tokens'
4+
import DropIcon from 'components/icons/DropIcon'
5+
import Icon from 'components/icons/Icon'
6+
import Button from 'components/base/forms/Button'
7+
import ErrorMessage from 'components/ErrorMessage'
8+
import './CsvUpload.scss'
9+
10+
export type CsvUploadType = {
11+
value: File | null
12+
rowCount?: number
13+
onChange: (file: File, text: string) => void
14+
}
15+
16+
const formatFileSize = (bytes: number) => {
17+
if (bytes >= 1024 * 1024) {
18+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
19+
}
20+
return `${(bytes / 1024).toFixed(1)} KB`
21+
}
22+
23+
const CsvUpload: FC<CsvUploadType> = ({ onChange, rowCount, value }) => {
24+
const [error, setError] = useState('')
25+
26+
const onDrop = useCallback(
27+
(acceptedFiles: File[]) => {
28+
setError('')
29+
const file = acceptedFiles[0]
30+
if (!file) {
31+
return
32+
}
33+
const reader = new FileReader()
34+
reader.addEventListener('load', () => {
35+
onChange(file, `${reader.result}`)
36+
})
37+
reader.addEventListener('error', () => {
38+
setError('Error reading file')
39+
})
40+
reader.readAsText(file)
41+
},
42+
[onChange],
43+
)
44+
45+
const { getInputProps, getRootProps, open } = useDropzone({
46+
accept: {
47+
'text/csv': ['.csv'],
48+
},
49+
multiple: false,
50+
noClick: true,
51+
noKeyboard: true,
52+
onDrop,
53+
onDropRejected: () => {
54+
setError('Please select a CSV file')
55+
},
56+
})
57+
58+
return (
59+
<div className='csv-upload'>
60+
<div {...getRootProps()}>
61+
<input {...getInputProps()} />
62+
{value ? (
63+
<div className='csv-upload__file-card d-flex align-items-center gap-3 p-3 rounded-lg bg-surface-default'>
64+
<span className='csv-upload__file-icon d-inline-flex align-items-center justify-content-center flex-shrink-0 rounded-md bg-surface-action-tint'>
65+
<Icon name='file-text' width={20} fill={colorIconAction} />
66+
</span>
67+
<div className='flex-fill overflow-hidden'>
68+
<div className='fw-semibold text-truncate'>{value.name}</div>
69+
<div className='fs-small text-secondary'>
70+
{formatFileSize(value.size)}
71+
{typeof rowCount === 'number' &&
72+
` · ${rowCount.toLocaleString()} ${
73+
rowCount === 1 ? 'row' : 'rows'
74+
}`}
75+
</div>
76+
</div>
77+
<Button theme='outline' onClick={open}>
78+
Replace file
79+
</Button>
80+
</div>
81+
) : (
82+
<div className='csv-upload__droparea text-center rounded-lg'>
83+
<DropIcon />
84+
<div className='mt-2 mb-2'>
85+
<strong>Drag and drop your CSV here</strong>
86+
</div>
87+
<div className='text-secondary fs-small mb-3'>
88+
or browse it from your computer
89+
</div>
90+
<Button onClick={open}>Select file</Button>
91+
</div>
92+
)}
93+
</div>
94+
{!!error && <ErrorMessage error={error} />}
95+
</div>
96+
)
97+
}
98+
99+
export default CsvUpload
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from './CsvUpload'

frontend/web/components/EnvironmentSelect.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type EnvironmentSelectType = Partial<Omit<Props, 'value'>> & {
1818
readOnly?: boolean
1919
idField?: 'id' | 'api_key'
2020
ignore?: string[]
21+
size?: 'default' | 'select-sm' | 'select-xsm'
2122
dataTest?: (value: { label: string }) => string
2223
}
2324

@@ -30,6 +31,7 @@ const EnvironmentSelect: FC<EnvironmentSelectType> = ({
3031
projectId,
3132
readOnly,
3233
showAll,
34+
size = 'select-xsm',
3335
value,
3436
...rest
3537
}) => {
@@ -64,7 +66,7 @@ const EnvironmentSelect: FC<EnvironmentSelectType> = ({
6466
<div data-test={dataTestProp}>
6567
<Select
6668
{...rest}
67-
className='react-select select-xsm'
69+
size={size === 'default' ? undefined : size}
6870
value={
6971
foundValue
7072
? foundValue

0 commit comments

Comments
 (0)