Skip to content
Open
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
82 changes: 82 additions & 0 deletions frontend/common/services/useCohort.ts
Original file line number Diff line number Diff line change
@@ -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<Res['cohort'], Req['createCohort']>({
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<void, Req['deleteCohort']>({
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<typeof cohortService.endpoints.deleteCohort.initiate>[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()
*/
21 changes: 21 additions & 0 deletions frontend/common/types/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
FeatureStateValue,
ImportStrategy,
Approval,
Metadata,
MultivariateOption,
SAMLConfiguration,
Segment,
Expand Down Expand Up @@ -169,6 +170,26 @@ export type Req = {
projectId: number
segment: Omit<Segment, 'id' | 'uuid' | 'project'>
}
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
}
Comment on lines +185 to +192

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Invalidate the Segment list after CSV synchronisation.

CSV synchronisation changes cohort membership. createCohort and deleteCohort invalidate LIST${projectId} for Segment, but syncCohortCsv cannot do this because its request has no projectId. Segment membership counts can remain stale after a successful upload.

  • frontend/common/types/requests.ts#L185-L191: add projectId to Req['syncCohortCsv'].
  • frontend/common/services/useCohort.ts#L39-L39: invalidate { id: \LIST${arg.projectId}`, type: 'Segment' }` with the Cohort tag.
  • frontend/web/components/modals/CreateSegmentFromCsv/CreateSegmentFromCsv.tsx#L113-L124: pass projectId: Number(projectId) to syncCohortCsv.
📍 Affects 3 files
  • frontend/common/types/requests.ts#L185-L191 (this comment)
  • frontend/common/services/useCohort.ts#L39-L39
  • frontend/web/components/modals/CreateSegmentFromCsv/CreateSegmentFromCsv.tsx#L113-L124

cloneSegment: {
projectId: number
segmentId: number
Expand Down
34 changes: 34 additions & 0 deletions frontend/common/types/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,14 @@ export type SegmentMembersResponse = PagedResponse<SegmentMember> & {
// 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[]
Expand All @@ -184,6 +192,7 @@ export type Segment = {
feature?: number
metadata: Metadata[] | []
membership_counts?: SegmentMembership[]
cohort?: SegmentCohort | null
}
export type ProjectChangeRequest = Omit<
ChangeRequest,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1305,6 +1337,8 @@ export type WarehouseConnection = {
export type Res = {
segments: PagedResponse<Segment>
segment: Segment
cohort: Cohort
cohortCsvSync: CohortCsvSyncResult
segmentMembers: SegmentMembersResponse
auditLogs: PagedResponse<AuditLogItem>
organisationLicence: {}
Expand Down
25 changes: 24 additions & 1 deletion frontend/common/utils/__tests__/csv.test.ts
Original file line number Diff line number Diff line change
@@ -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[][]][] = [
Expand Down Expand Up @@ -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,
)
})
})
13 changes: 12 additions & 1 deletion frontend/common/utils/csv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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')
}
23 changes: 19 additions & 4 deletions frontend/web/components/CsvUpload/CsvUpload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import './CsvUpload.scss'

export type CsvUploadType = {
value: File | null
maxSizeBytes?: number
rowCount?: number
onChange: (file: File, text: string) => void
}
Expand All @@ -20,7 +21,12 @@ const formatFileSize = (bytes: number) => {
return `${(bytes / 1024).toFixed(1)} KB`
}

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

const onDrop = useCallback(
Expand All @@ -46,12 +52,17 @@ const CsvUpload: FC<CsvUploadType> = ({ 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',
)
},
})

Expand Down Expand Up @@ -91,7 +102,11 @@ const CsvUpload: FC<CsvUploadType> = ({ onChange, rowCount, value }) => {
</div>
)}
</div>
{!!error && <ErrorMessage error={error} />}
{!!error && (
<div className='mt-3'>
<ErrorMessage error={error} />
</div>
)}
</div>
)
}
Expand Down
26 changes: 24 additions & 2 deletions frontend/web/components/modals/ConfirmRemoveSegment.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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(
<div>
Expand Down
Loading
Loading