From 166aae9905eddc81664213452c98ad112cf30299 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 14 Aug 2026 16:27:10 +0200 Subject: [PATCH] feat: wire CSV segments to the cohorts API --- frontend/common/services/useCohort.ts | 82 ++++++++++++++++ frontend/common/types/requests.ts | 21 ++++ frontend/common/types/responses.ts | 34 +++++++ frontend/common/utils/__tests__/csv.test.ts | 25 ++++- frontend/common/utils/csv.ts | 13 ++- .../web/components/CsvUpload/CsvUpload.tsx | 23 ++++- .../modals/ConfirmRemoveSegment.tsx | 26 ++++- .../CreateSegmentFromCsv.tsx | 95 +++++++++++++++++-- .../segments/SegmentRow/SegmentRow.tsx | 56 +++++++++-- .../SegmentRow/components/SegmentAction.tsx | 2 + 10 files changed, 352 insertions(+), 25 deletions(-) create mode 100644 frontend/common/services/useCohort.ts diff --git a/frontend/common/services/useCohort.ts b/frontend/common/services/useCohort.ts new file mode 100644 index 000000000000..022a0268ee7b --- /dev/null +++ b/frontend/common/services/useCohort.ts @@ -0,0 +1,82 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' +import toFormData from 'common/utils/toFormData' + +export const cohortService = service + .enhanceEndpoints({ addTagTypes: ['Cohort', 'Segment'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + createCohort: builder.mutation({ + invalidatesTags: (q, e, arg) => [ + { id: 'LIST', type: 'Cohort' }, + { id: `LIST${arg.projectId}`, type: 'Segment' }, + ], + query: (query) => ({ + body: { + description: query.description, + metadata: query.metadata, + name: query.name, + }, + method: 'POST', + url: `environments/${query.environmentApiKey}/cohorts/`, + }), + }), + deleteCohort: builder.mutation({ + invalidatesTags: (q, e, arg) => [ + { id: 'LIST', type: 'Cohort' }, + { id: `LIST${arg.projectId}`, type: 'Segment' }, + ], + query: (query) => ({ + method: 'DELETE', + url: `environments/${query.environmentApiKey}/cohorts/${query.cohortId}/`, + }), + }), + syncCohortCsv: builder.mutation< + Res['cohortCsvSync'], + Req['syncCohortCsv'] + >({ + invalidatesTags: (q, e, arg) => [ + { id: arg.cohortId, type: 'Cohort' }, + { id: `LIST${arg.projectId}`, type: 'Segment' }, + ], + queryFn: async (query, baseQueryApi, extraOptions, baseQuery) => { + // projectId only feeds tag invalidation; keep it out of the form data. + const { cohortId, environmentApiKey, projectId: _, ...rest } = query + const formData = toFormData({ ...rest }) + const { data, error } = await baseQuery({ + body: formData, + method: 'POST', + url: `environments/${environmentApiKey}/cohorts/${cohortId}/sync-csv/`, + }) + return { data, error } as { + data: Res['cohortCsvSync'] + error: never + } + }, + }), + // END OF ENDPOINTS + }), + }) + +export async function deleteCohort( + store: any, + data: Req['deleteCohort'], + options?: Parameters[1], +) { + return store.dispatch( + cohortService.endpoints.deleteCohort.initiate(data, options), + ) +} + +export const { + useCreateCohortMutation, + useDeleteCohortMutation, + useSyncCohortCsvMutation, + // END OF EXPORTS +} = cohortService + +/* Usage examples: +const [createCohort, { isLoading, data, isSuccess }] = useCreateCohortMutation() +const [syncCohortCsv, { isLoading }] = useSyncCohortCsvMutation() +*/ diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 0f91cc478657..62565eb1b873 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -5,6 +5,7 @@ import { FeatureStateValue, ImportStrategy, Approval, + Metadata, MultivariateOption, SAMLConfiguration, Segment, @@ -169,6 +170,26 @@ export type Req = { projectId: number segment: Omit } + createCohort: { + environmentApiKey: string + projectId: number + name: string + description?: string + metadata?: Metadata[] + } + deleteCohort: { + environmentApiKey: string + cohortId: number + projectId: number + } + syncCohortCsv: { + environmentApiKey: string + cohortId: number + projectId: number + file: File + identifier_column?: number + has_header?: boolean + } cloneSegment: { projectId: number segmentId: number diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index bc0e867ec39c..c97ebb61ac2f 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -174,6 +174,14 @@ export type SegmentMembersResponse = PagedResponse & { // Pass as `cursor` to fetch the next page; null when there are no more rows. next_cursor: string | null } +export type SegmentCohort = { + id: number + environment: number + source_type: 'csv' + version: number + deletion_requested_at: string | null +} + export type Segment = { id: number rules: SegmentRule[] @@ -184,6 +192,7 @@ export type Segment = { feature?: number metadata: Metadata[] | [] membership_counts?: SegmentMembership[] + cohort?: SegmentCohort | null } export type ProjectChangeRequest = Omit< ChangeRequest, @@ -994,6 +1003,29 @@ export type Metadata = { field_value: string } +export type Cohort = { + id: number + uuid: string + name: string + description: string | null + segment: number + source_type: 'csv' + version: number + created_at: string +} + +export type CohortCsvSyncResult = { + version: number + added: number + removed: number + unchanged: number + ignored: { + empty: number + duplicates: number + too_long: number + } +} + export type MetadataFieldModelField = { id: number content_type: number @@ -1305,6 +1337,8 @@ export type WarehouseConnection = { export type Res = { segments: PagedResponse segment: Segment + cohort: Cohort + cohortCsvSync: CohortCsvSyncResult segmentMembers: SegmentMembersResponse auditLogs: PagedResponse organisationLicence: {} diff --git a/frontend/common/utils/__tests__/csv.test.ts b/frontend/common/utils/__tests__/csv.test.ts index 1d6e0664b7c1..ca17f4e500f9 100644 --- a/frontend/common/utils/__tests__/csv.test.ts +++ b/frontend/common/utils/__tests__/csv.test.ts @@ -1,4 +1,9 @@ -import { extractIdentifiers, parseCsvText, toParsedCsv } from 'common/utils/csv' +import { + extractIdentifiers, + parseCsvText, + toCsvColumn, + toParsedCsv, +} from 'common/utils/csv' describe('parseCsvText', () => { const cases: [string, string, string[][]][] = [ @@ -79,3 +84,21 @@ describe('extractIdentifiers', () => { }) }) }) + +describe('toCsvColumn', () => { + test.each([ + ['plain values', ['a', 'b'], 'a\nb'], + ['comma quoted', ['Doe, Jane', 'b'], '"Doe, Jane"\nb'], + ['quote escaped', ['say "hi"'], '"say ""hi"""'], + ['newline quoted', ['line1\nline2'], '"line1\nline2"'], + ])('%s', (_, values, expected) => { + expect(toCsvColumn(values)).toEqual(expected) + }) + + test('round-trips through parseCsvText', () => { + const values = ['plain', 'Doe, Jane', 'say "hi"', 'multi\nline'] + expect(parseCsvText(toCsvColumn(values)).map((row) => row[0])).toEqual( + values, + ) + }) +}) diff --git a/frontend/common/utils/csv.ts b/frontend/common/utils/csv.ts index e99824e4c448..8c211539ea1b 100644 --- a/frontend/common/utils/csv.ts +++ b/frontend/common/utils/csv.ts @@ -58,7 +58,10 @@ export function toParsedCsv( if (!rawRows.length) { return { columns: [], rows: [] } } - const columnCount = Math.max(...rawRows.map((cells) => cells.length)) + const columnCount = rawRows.reduce( + (max, cells) => Math.max(max, cells.length), + 0, + ) if (hasHeaders) { const [header, ...rows] = rawRows return { @@ -96,3 +99,11 @@ export function extractIdentifiers( } return { duplicateCount, emptyCount, identifiers } } + +export function toCsvColumn(values: string[]): string { + return values + .map((value) => + /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value, + ) + .join('\n') +} diff --git a/frontend/web/components/CsvUpload/CsvUpload.tsx b/frontend/web/components/CsvUpload/CsvUpload.tsx index f1e93ebbb93d..9fa6668ab757 100644 --- a/frontend/web/components/CsvUpload/CsvUpload.tsx +++ b/frontend/web/components/CsvUpload/CsvUpload.tsx @@ -9,6 +9,7 @@ import './CsvUpload.scss' export type CsvUploadType = { value: File | null + maxSizeBytes?: number rowCount?: number onChange: (file: File, text: string) => void } @@ -20,7 +21,12 @@ const formatFileSize = (bytes: number) => { return `${(bytes / 1024).toFixed(1)} KB` } -const CsvUpload: FC = ({ onChange, rowCount, value }) => { +const CsvUpload: FC = ({ + maxSizeBytes, + onChange, + rowCount, + value, +}) => { const [error, setError] = useState('') const onDrop = useCallback( @@ -46,12 +52,17 @@ const CsvUpload: FC = ({ onChange, rowCount, value }) => { accept: { 'text/csv': ['.csv'], }, + maxSize: maxSizeBytes, multiple: false, noClick: true, noKeyboard: true, onDrop, - onDropRejected: () => { - setError('Please select a CSV file') + onDropRejected: (rejections) => { + setError( + rejections[0]?.errors?.[0]?.code === 'file-too-large' && maxSizeBytes + ? `Please select a file smaller than ${formatFileSize(maxSizeBytes)}` + : 'Please select a CSV file', + ) }, }) @@ -91,7 +102,11 @@ const CsvUpload: FC = ({ onChange, rowCount, value }) => { )} - {!!error && } + {!!error && ( +
+ +
+ )} ) } diff --git a/frontend/web/components/modals/ConfirmRemoveSegment.tsx b/frontend/web/components/modals/ConfirmRemoveSegment.tsx index d1de098dff78..b9f6eec4420e 100644 --- a/frontend/web/components/modals/ConfirmRemoveSegment.tsx +++ b/frontend/web/components/modals/ConfirmRemoveSegment.tsx @@ -1,11 +1,13 @@ import React, { FC, FormEvent, useState } from 'react' -import { Segment } from 'common/types/responses' +import { Environment, Segment, SegmentCohort } from 'common/types/responses' import ProjectProvider from 'common/providers/ProjectProvider' import InputGroup from 'components/base/forms/InputGroup' import Utils from 'common/utils/utils' import Button from 'components/base/forms/Button' import ModalHR from './ModalHR' import { deleteSegment } from 'common/services/useSegment' +import { deleteCohort } from 'common/services/useCohort' +import { getEnvironments } from 'common/services/useEnvironment' import { getStore } from 'common/store' type ConfirmRemoveSegmentType = { @@ -17,9 +19,29 @@ export const handleRemoveSegment = ( segment: Segment, onComplete?: () => void, ) => { + // Cohort-managed segments must be deleted via their cohort; the segment + // endpoint rejects them. + const removeCohort = async (cohort: SegmentCohort) => { + const { data: environments } = await getEnvironments(getStore(), { + projectId: Number(projectId), + }) + const environmentApiKey = environments?.results?.find( + (environment: Environment) => environment.id === cohort.environment, + )?.api_key + if (!environmentApiKey) { + throw new Error('Cohort environment not found') + } + return deleteCohort(getStore(), { + cohortId: cohort.id, + environmentApiKey, + projectId: Number(projectId), + }) + } const removeSegmentCallback = async () => { try { - const res = await deleteSegment(getStore(), { id: segment.id, projectId }) + const res = segment.cohort + ? await removeCohort(segment.cohort) + : await deleteSegment(getStore(), { id: segment.id, projectId }) if (res.error) throw new Error(res.error) toast(
diff --git a/frontend/web/components/modals/CreateSegmentFromCsv/CreateSegmentFromCsv.tsx b/frontend/web/components/modals/CreateSegmentFromCsv/CreateSegmentFromCsv.tsx index 734e701cb858..3a6a16e5d01a 100644 --- a/frontend/web/components/modals/CreateSegmentFromCsv/CreateSegmentFromCsv.tsx +++ b/frontend/web/components/modals/CreateSegmentFromCsv/CreateSegmentFromCsv.tsx @@ -3,8 +3,18 @@ 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 { + extractIdentifiers, + parseCsvText, + toCsvColumn, + toParsedCsv, +} from 'common/utils/csv' import { useGetSupportedContentTypeQuery } from 'common/services/useSupportedContentType' +import { + useCreateCohortMutation, + useSyncCohortCsvMutation, +} from 'common/services/useCohort' +import { Metadata } from 'common/types/responses' import AccountStore from 'common/stores/account-store' import { colorIconSuccess } from 'common/theme/tokens' import Button from 'components/base/forms/Button' @@ -21,6 +31,8 @@ import Tabs from 'components/navigation/TabMenu/Tabs' import './CreateSegmentFromCsv.scss' const PREVIEW_ROW_COUNT = 5 +// Mirrors the API's COHORT_CSV_MAX_FILE_SIZE_BYTES. +const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024 type CreateSegmentFromCsvType = { projectId: number | string @@ -35,6 +47,14 @@ const CreateSegmentFromCsv: FC = ({ projectId }) => { const [hasHeaders, setHasHeaders] = useState(true) const [selectedColumn, setSelectedColumn] = useState(null) const [tab, setTab] = useState(0) + const [metadata, setMetadata] = useState([]) + const [createdCohortId, setCreatedCohortId] = useState(null) + + const [createCohort, { error: createError, isLoading: isCreating }] = + useCreateCohortMutation() + const [syncCohortCsv, { error: syncError, isLoading: isSyncing }] = + useSyncCohortCsvMutation() + const isSaving = isCreating || isSyncing const metadataEnable = Utils.getPlansPermission('METADATA') const { data: supportedContentTypes } = useGetSupportedContentTypeQuery({ @@ -60,9 +80,25 @@ const CreateSegmentFromCsv: FC = ({ projectId }) => { [parsed.rows, columnIndex], ) + // Only the identifier column leaves the browser. + const csvColumn = useMemo( + () => (extraction ? toCsvColumn(extraction.identifiers) : ''), + [extraction], + ) + // Quoting can expand values, so the generated upload needs its own check. + const isUploadTooLarge = useMemo( + () => new Blob([csvColumn]).size > MAX_FILE_SIZE_BYTES, + [csvColumn], + ) + const isBlocked = !!extraction && !extraction.identifiers.length const canSubmit = - !!name && !!environmentId && !!file && !!extraction && !isBlocked + !!name && + !!environmentId && + !!file && + !!extraction && + !isBlocked && + !isUploadTooLarge const onFile = (newFile: File, text: string) => { setFile(newFile) @@ -70,9 +106,43 @@ const CreateSegmentFromCsv: FC = ({ projectId }) => { setSelectedColumn(null) } - const save = (e: FormEvent) => { + const save = async (e: FormEvent) => { e.preventDefault() - // TODO: submit to the cohorts API once the creation endpoint exists + if (!canSubmit || !extraction) { + return + } + try { + // Keep the created cohort across a failed sync so retrying only syncs. + let cohortId = createdCohortId + if (cohortId === null) { + const cohort = await createCohort({ + description: description || undefined, + environmentApiKey: environmentId, + metadata, + name, + projectId: Number(projectId), + }).unwrap() + cohortId = cohort.id + setCreatedCohortId(cohortId) + } + const result = await syncCohortCsv({ + cohortId, + environmentApiKey: environmentId, + file: new File([csvColumn], 'identifiers.csv', { type: 'text/csv' }), + has_header: false, + projectId: Number(projectId), + }).unwrap() + toast( + `Segment created with ${result.added} ${ + result.added === 1 ? 'identity' : 'identities' + }`, + 'success', + 10000, + ) + closeModal() + } catch { + // Errors surface via the mutation error states below. + } } const columnName = columnIndex === null ? '' : parsed.columns[columnIndex] @@ -128,7 +198,6 @@ const CreateSegmentFromCsv: FC = ({ projectId }) => {
setEnvironmentId(`${value}`)} @@ -141,6 +210,7 @@ const CreateSegmentFromCsv: FC = ({ projectId }) => {
@@ -249,11 +319,21 @@ const CreateSegmentFromCsv: FC = ({ projectId }) => { error={`No valid identifiers found in "${columnName}". Choose a different column or check your file.`} /> )} + {isUploadTooLarge && ( + + )} )} + {!!(createError || syncError) && ( + + )}
-
@@ -280,6 +360,7 @@ const CreateSegmentFromCsv: FC = ({ projectId }) => { projectId={Number(projectId)} entityContentType={segmentContentType.id} entity={segmentContentType.model} + onChange={(m) => setMetadata(m as Metadata[])} /> } /> diff --git a/frontend/web/components/segments/SegmentRow/SegmentRow.tsx b/frontend/web/components/segments/SegmentRow/SegmentRow.tsx index 758d27e6f662..0a3486aa646d 100644 --- a/frontend/web/components/segments/SegmentRow/SegmentRow.tsx +++ b/frontend/web/components/segments/SegmentRow/SegmentRow.tsx @@ -1,4 +1,5 @@ import { FC } from 'react' +import classNames from 'classnames' import { useHistory } from 'react-router-dom' import { useHasPermission } from 'common/providers/Permission' @@ -6,8 +7,10 @@ import { useHasPermission } from 'common/providers/Permission' import { Segment } from 'common/types/responses' import SegmentAction from './components/SegmentAction' import { SegmentMembershipTotalBadge } from 'components/segments/SegmentMembershipBadge' +import Chip from 'components/base/Chip' import ConfirmCloneSegment from 'components/modals/ConfirmCloneSegment' import { useCloneSegmentMutation } from 'common/services/useSegment' +import { useGetEnvironmentsQuery } from 'common/services/useEnvironment' import { handleRemoveSegment } from 'components/modals/ConfirmRemoveSegment' import { ProjectPermission } from 'common/types/permissions.types' @@ -19,7 +22,16 @@ interface SegmentRowProps { const SegmentRow: FC = ({ index, projectId, segment }) => { const history = useHistory() - const { description, feature, id, name } = segment + const { cohort, description, feature, id, name } = segment + + const { data: environments } = useGetEnvironmentsQuery( + { projectId: Number(projectId) }, + { skip: !cohort }, + ) + const cohortEnvironment = environments?.results?.find( + (environment) => environment.id === cohort?.environment, + ) + const isPendingDeletion = !!cohort?.deletion_requested_at const { permission: manageSegmentsPermission } = useHasPermission({ id: projectId, @@ -65,11 +77,18 @@ const SegmentRow: FC = ({ index, projectId, segment }) => { } return ( - + history.push( `${document.location.pathname.replace(/\/$/, '')}/${id}`, @@ -82,6 +101,21 @@ const SegmentRow: FC = ({ index, projectId, segment }) => { {feature && (
Feature-Specific
)} + {!!cohort && ( + + {cohort.source_type.toUpperCase()} + + )} + {!!cohort && !!cohortEnvironment && ( + + {cohortEnvironment.name} + + )} + {isPendingDeletion && ( + + Deleting + + )} @@ -91,13 +125,15 @@ const SegmentRow: FC = ({ index, projectId, segment }) => {
- + {!isPendingDeletion && ( + + )}
) diff --git a/frontend/web/components/segments/SegmentRow/components/SegmentAction.tsx b/frontend/web/components/segments/SegmentRow/components/SegmentAction.tsx index 15e60d87da5d..d5c894b30672 100644 --- a/frontend/web/components/segments/SegmentRow/components/SegmentAction.tsx +++ b/frontend/web/components/segments/SegmentRow/components/SegmentAction.tsx @@ -52,6 +52,7 @@ const SegmentAction: FC = ({ icon={} label='Clone Segment' handleActionClick={() => { + setIsOpen(false) onClone() }} entity='segment' @@ -64,6 +65,7 @@ const SegmentAction: FC = ({ icon={} label='Remove Segment' handleActionClick={() => { + setIsOpen(false) onRemove() }} entity='segment'