diff --git a/frontend/common/utils/__tests__/csv.test.ts b/frontend/common/utils/__tests__/csv.test.ts new file mode 100644 index 000000000000..1edac25ad38a --- /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..bf886a98ea3f --- /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/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..b293f7e9e84c --- /dev/null +++ b/frontend/web/components/CsvUpload/CsvUpload.scss @@ -0,0 +1,5 @@ +.csv-upload { + .droparea { + border-color: var(--color-border-action); + } +} diff --git a/frontend/web/components/CsvUpload/CsvUpload.tsx b/frontend/web/components/CsvUpload/CsvUpload.tsx new file mode 100644 index 000000000000..467d4ed6703f --- /dev/null +++ b/frontend/web/components/CsvUpload/CsvUpload.tsx @@ -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 = ({ 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 ( +
+ {value ? ( + +
+ +
+ +
+ {value.name} +
+ +
+
+
+ ) : ( +
+ +
+ +
+ Select a file or drag and drop here +
+
CSV File
+ +
+
+ )} + {!!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/modals/CreateSegmentFromCsv/CreateSegmentFromCsv.tsx b/frontend/web/components/modals/CreateSegmentFromCsv/CreateSegmentFromCsv.tsx new file mode 100644 index 000000000000..1d84e929c8a3 --- /dev/null +++ b/frontend/web/components/modals/CreateSegmentFromCsv/CreateSegmentFromCsv.tsx @@ -0,0 +1,266 @@ +import React, { FC, FormEvent, useMemo, useState } from 'react' +import classNames from 'classnames' +import Constants from 'common/constants' +import Format from 'common/utils/format' +import Utils from 'common/utils/utils' +import { extractIdentifiers, parseCsvText, toParsedCsv } from 'common/utils/csv' +import { useGetSupportedContentTypeQuery } from 'common/services/useSupportedContentType' +import AccountStore from 'common/stores/account-store' +import Button from 'components/base/forms/Button' +import Checkbox from 'components/base/forms/Checkbox' +import Input from 'components/base/forms/Input' +import InputGroup from 'components/base/forms/InputGroup' +import CsvUpload from 'components/CsvUpload' +import EnvironmentSelect from 'components/EnvironmentSelect' +import ErrorMessage from 'components/ErrorMessage' +import WarningMessage from 'components/WarningMessage' +import AddMetadataToEntity from 'components/metadata/AddMetadataToEntity' +import TabItem from 'components/navigation/TabMenu/TabItem' +import Tabs from 'components/navigation/TabMenu/Tabs' + +const PREVIEW_ROW_COUNT = 5 + +type CreateSegmentFromCsvType = { + projectId: number | string +} + +const CreateSegmentFromCsv: FC = ({ projectId }) => { + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [environmentId, setEnvironmentId] = useState('') + const [file, setFile] = useState(null) + const [rawRows, setRawRows] = useState([]) + const [hasHeaders, setHasHeaders] = useState(true) + const [selectedColumn, setSelectedColumn] = useState(null) + const [tab, setTab] = useState(0) + + const metadataEnable = Utils.getPlansPermission('METADATA') + const { data: supportedContentTypes } = useGetSupportedContentTypeQuery({ + organisation_id: AccountStore.getOrganisation().id, + }) + const segmentContentType = useMemo( + () => + supportedContentTypes && + Utils.getContentType(supportedContentTypes, 'model', 'segment'), + [supportedContentTypes], + ) + + const parsed = useMemo( + () => toParsedCsv(rawRows, hasHeaders), + [rawRows, hasHeaders], + ) + const columnIndex = parsed.columns.length === 1 ? 0 : selectedColumn + const extraction = useMemo( + () => + columnIndex === null + ? null + : extractIdentifiers(parsed.rows, columnIndex), + [parsed.rows, columnIndex], + ) + + const ignoredCount = extraction + ? extraction.emptyCount + extraction.duplicateCount + : 0 + const isBlocked = !!extraction && !extraction.identifiers.length + const canSubmit = + !!name && !!environmentId && !!file && !!extraction && !isBlocked + + const onFile = (newFile: File, text: string) => { + setFile(newFile) + setRawRows(parseCsvText(text)) + setSelectedColumn(null) + } + + const save = (e: FormEvent) => { + e.preventDefault() + // TODO: submit to the cohorts API once the creation endpoint exists + } + + const columnName = columnIndex === null ? '' : parsed.columns[columnIndex] + let fileError = null + if (file && !parsed.columns.length) { + fileError = 'The file appears to be empty.' + } else if (file && !parsed.rows.length) { + fileError = 'No data rows found โ€” the file only contains a header row.' + } + + const form = ( +
+
+ + + ) => { + setName( + Format.enumeration + .set(Utils.safeParseEventValue(e)) + .toLowerCase(), + ) + }} + isValid={!!name?.length} + type='text' + placeholder='E.g. power_users' + /> + +
+ ) => { + setDescription(Utils.safeParseEventValue(e)) + }} + type='text' + title='Description' + placeholder="e.g. 'People who have spent over $100' " + /> +
+ + setEnvironmentId(`${value}`)} + /> +
+ The uploaded identities will be targeted in this environment only. +
+
+
+ +
+ {!!file && !!parsed.columns.length && ( +
+ +
+ )} + {!!fileError && } + {!!file && !fileError && ( + <> +
+ +