From 033c67d750808e91a4f9fb4f676cc24a00550028 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 11 Aug 2026 16:54:07 +0200 Subject: [PATCH] feat: create segment from CSV drawer --- frontend/common/theme/tokens.json | 1 + frontend/common/theme/tokens.ts | 2 + frontend/common/utils/__tests__/csv.test.ts | 81 +++++ frontend/common/utils/csv.ts | 98 ++++++ .../TokenReference.generated.stories.tsx | 8 + .../e2e/helpers/e2e-helpers.playwright.ts | 13 +- .../web/components/CsvUpload/CsvUpload.scss | 15 + .../web/components/CsvUpload/CsvUpload.tsx | 99 ++++++ frontend/web/components/CsvUpload/index.ts | 1 + frontend/web/components/EnvironmentSelect.tsx | 4 +- .../web/components/base/forms/Checkbox.tsx | 2 +- .../CreateSegmentFromCsv.scss | 38 +++ .../CreateSegmentFromCsv.tsx | 292 ++++++++++++++++++ .../modals/CreateSegmentFromCsv/index.ts | 1 + .../CreateSegmentSourcesModal.tsx | 85 +++-- .../modals/CreateSegmentSourcesModal/index.ts | 3 +- frontend/web/components/modals/base/Modal.tsx | 6 +- .../web/components/pages/SegmentsPage.tsx | 31 +- .../web/styles/3rdParty/_react-select.scss | 3 + frontend/web/styles/_token-utilities.scss | 1 + frontend/web/styles/_tokens.scss | 2 + 21 files changed, 756 insertions(+), 30 deletions(-) create mode 100644 frontend/common/utils/__tests__/csv.test.ts create mode 100644 frontend/common/utils/csv.ts create mode 100644 frontend/web/components/CsvUpload/CsvUpload.scss create mode 100644 frontend/web/components/CsvUpload/CsvUpload.tsx create mode 100644 frontend/web/components/CsvUpload/index.ts create mode 100644 frontend/web/components/modals/CreateSegmentFromCsv/CreateSegmentFromCsv.scss create mode 100644 frontend/web/components/modals/CreateSegmentFromCsv/CreateSegmentFromCsv.tsx create mode 100644 frontend/web/components/modals/CreateSegmentFromCsv/index.ts diff --git a/frontend/common/theme/tokens.json b/frontend/common/theme/tokens.json index 4e2312aad725..28ecd0fcdb6a 100644 --- a/frontend/common/theme/tokens.json +++ b/frontend/common/theme/tokens.json @@ -94,6 +94,7 @@ "action-active": { "cssVar": "--color-surface-action-active", "light": "#3919b7", "dark": "#4e25db" }, "action-subtle": { "cssVar": "--color-surface-action-subtle", "light": "rgba(104, 55, 252, 0.08)", "dark": "rgba(255, 255, 255, 0.08)" }, "action-muted": { "cssVar": "--color-surface-action-muted", "light": "rgba(104, 55, 252, 0.16)", "dark": "rgba(255, 255, 255, 0.16)" }, + "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." }, "danger": { "cssVar": "--color-surface-danger", "light": "rgba(239, 77, 86, 0.08)", "dark": "oklch(from var(--red-500) 0.18 0.02 h)" }, "success": { "cssVar": "--color-surface-success", "light": "rgba(39, 171, 149, 0.08)", "dark": "oklch(from var(--green-500) 0.18 0.02 h)" }, "warning": { "cssVar": "--color-surface-warning", "light": "rgba(255, 159, 67, 0.08)", "dark": "oklch(from var(--orange-500) 0.18 0.02 h)" }, diff --git a/frontend/common/theme/tokens.ts b/frontend/common/theme/tokens.ts index d9e244ea0838..5caa021f0f54 100644 --- a/frontend/common/theme/tokens.ts +++ b/frontend/common/theme/tokens.ts @@ -179,6 +179,8 @@ export const colorSurfaceActionMuted = 'var(--color-surface-action-muted, rgba(104, 55, 252, 0.16))' export const colorSurfaceActionSubtle = 'var(--color-surface-action-subtle, rgba(104, 55, 252, 0.08))' +export const colorSurfaceActionTint = + 'var(--color-surface-action-tint, rgba(104, 55, 252, 0.12))' export const colorSurfaceActive = 'var(--color-surface-active, rgba(0, 0, 0, 0.16))' export const colorSurfaceDanger = diff --git a/frontend/common/utils/__tests__/csv.test.ts b/frontend/common/utils/__tests__/csv.test.ts new file mode 100644 index 000000000000..1d6e0664b7c1 --- /dev/null +++ b/frontend/common/utils/__tests__/csv.test.ts @@ -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'], + }) + }) +}) diff --git a/frontend/common/utils/csv.ts b/frontend/common/utils/csv.ts new file mode 100644 index 000000000000..e99824e4c448 --- /dev/null +++ b/frontend/common/utils/csv.ts @@ -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() + 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 } +} diff --git a/frontend/documentation/TokenReference.generated.stories.tsx b/frontend/documentation/TokenReference.generated.stories.tsx index 8a8326e98877..f0ecbc20aafb 100644 --- a/frontend/documentation/TokenReference.generated.stories.tsx +++ b/frontend/documentation/TokenReference.generated.stories.tsx @@ -117,6 +117,14 @@ export const AllTokens: StoryObj = { oklch(from var(--purple-600) l c h / 0.16) + + + --color-surface-action-tint + + + oklch(from var(--purple-600) l c h / 0.12) + + --color-surface-danger diff --git a/frontend/e2e/helpers/e2e-helpers.playwright.ts b/frontend/e2e/helpers/e2e-helpers.playwright.ts index f332559af082..85ded873745a 100644 --- a/frontend/e2e/helpers/e2e-helpers.playwright.ts +++ b/frontend/e2e/helpers/e2e-helpers.playwright.ts @@ -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); diff --git a/frontend/web/components/CsvUpload/CsvUpload.scss b/frontend/web/components/CsvUpload/CsvUpload.scss new file mode 100644 index 000000000000..1ca9e74046ce --- /dev/null +++ b/frontend/web/components/CsvUpload/CsvUpload.scss @@ -0,0 +1,15 @@ +.csv-upload { + &__droparea { + padding: 32px; + border: 1px dashed var(--color-border-action); + } + + &__file-card { + border: 1px solid var(--color-border-default); + } + + &__file-icon { + width: 36px; + height: 36px; + } +} diff --git a/frontend/web/components/CsvUpload/CsvUpload.tsx b/frontend/web/components/CsvUpload/CsvUpload.tsx new file mode 100644 index 000000000000..f1e93ebbb93d --- /dev/null +++ b/frontend/web/components/CsvUpload/CsvUpload.tsx @@ -0,0 +1,99 @@ +import { FC, useCallback, useState } from 'react' +import { useDropzone } from 'react-dropzone' +import { colorIconAction } from 'common/theme/tokens' +import DropIcon from 'components/icons/DropIcon' +import Icon from 'components/icons/Icon' +import Button from 'components/base/forms/Button' +import ErrorMessage from 'components/ErrorMessage' +import './CsvUpload.scss' + +export type CsvUploadType = { + value: File | null + rowCount?: number + onChange: (file: File, text: string) => void +} + +const formatFileSize = (bytes: number) => { + if (bytes >= 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` + } + return `${(bytes / 1024).toFixed(1)} KB` +} + +const CsvUpload: FC = ({ onChange, rowCount, 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, open } = useDropzone({ + accept: { + 'text/csv': ['.csv'], + }, + multiple: false, + noClick: true, + noKeyboard: true, + onDrop, + onDropRejected: () => { + setError('Please select a CSV file') + }, + }) + + return ( +
+
+ + {value ? ( +
+ + + +
+
{value.name}
+
+ {formatFileSize(value.size)} + {typeof rowCount === 'number' && + ` ยท ${rowCount.toLocaleString()} ${ + rowCount === 1 ? 'row' : 'rows' + }`} +
+
+ +
+ ) : ( +
+ +
+ Drag and drop your CSV here +
+
+ or browse it from your computer +
+ +
+ )} +
+ {!!error && } +
+ ) +} + +export default CsvUpload diff --git a/frontend/web/components/CsvUpload/index.ts b/frontend/web/components/CsvUpload/index.ts new file mode 100644 index 000000000000..856df64ef662 --- /dev/null +++ b/frontend/web/components/CsvUpload/index.ts @@ -0,0 +1 @@ +export { default } from './CsvUpload' diff --git a/frontend/web/components/EnvironmentSelect.tsx b/frontend/web/components/EnvironmentSelect.tsx index 8636953ed48c..f7d637adada4 100644 --- a/frontend/web/components/EnvironmentSelect.tsx +++ b/frontend/web/components/EnvironmentSelect.tsx @@ -18,6 +18,7 @@ type EnvironmentSelectType = Partial> & { readOnly?: boolean idField?: 'id' | 'api_key' ignore?: string[] + size?: 'default' | 'select-sm' | 'select-xsm' dataTest?: (value: { label: string }) => string } @@ -30,6 +31,7 @@ const EnvironmentSelect: FC = ({ projectId, readOnly, showAll, + size = 'select-xsm', value, ...rest }) => { @@ -64,7 +66,7 @@ const EnvironmentSelect: FC = ({