Skip to content

Commit d0485cc

Browse files
authored
feat: wire CSV segments to the cohorts API (#8295)
1 parent 3ff27da commit d0485cc

14 files changed

Lines changed: 518 additions & 35 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { Res } from 'common/types/responses'
2+
import { Req } from 'common/types/requests'
3+
import { service } from 'common/service'
4+
import toFormData from 'common/utils/toFormData'
5+
6+
export const cohortService = service
7+
.enhanceEndpoints({ addTagTypes: ['Cohort', 'Segment'] })
8+
.injectEndpoints({
9+
endpoints: (builder) => ({
10+
createCohort: builder.mutation<Res['cohort'], Req['createCohort']>({
11+
invalidatesTags: (q, e, arg) => [
12+
{ id: 'LIST', type: 'Cohort' },
13+
{ id: `LIST${arg.projectId}`, type: 'Segment' },
14+
],
15+
query: (query) => ({
16+
body: {
17+
description: query.description,
18+
metadata: query.metadata,
19+
name: query.name,
20+
},
21+
method: 'POST',
22+
url: `environments/${query.environmentApiKey}/cohorts/`,
23+
}),
24+
}),
25+
deleteCohort: builder.mutation<void, Req['deleteCohort']>({
26+
invalidatesTags: (q, e, arg) => [
27+
{ id: 'LIST', type: 'Cohort' },
28+
{ id: `LIST${arg.projectId}`, type: 'Segment' },
29+
],
30+
query: (query) => ({
31+
method: 'DELETE',
32+
url: `environments/${query.environmentApiKey}/cohorts/${query.cohortId}/`,
33+
}),
34+
}),
35+
syncCohortCsv: builder.mutation<
36+
Res['cohortCsvSync'],
37+
Req['syncCohortCsv']
38+
>({
39+
invalidatesTags: (q, e, arg) => [
40+
{ id: arg.cohortId, type: 'Cohort' },
41+
{ id: `LIST${arg.projectId}`, type: 'Segment' },
42+
],
43+
queryFn: async (query, baseQueryApi, extraOptions, baseQuery) => {
44+
// projectId only feeds tag invalidation; keep it out of the form data.
45+
const { cohortId, environmentApiKey, projectId: _, ...rest } = query
46+
const formData = toFormData({ ...rest })
47+
const { data, error } = await baseQuery({
48+
body: formData,
49+
method: 'POST',
50+
url: `environments/${environmentApiKey}/cohorts/${cohortId}/sync-csv/`,
51+
})
52+
return { data, error } as {
53+
data: Res['cohortCsvSync']
54+
error: never
55+
}
56+
},
57+
}),
58+
// END OF ENDPOINTS
59+
}),
60+
})
61+
62+
export async function deleteCohort(
63+
store: any,
64+
data: Req['deleteCohort'],
65+
options?: Parameters<typeof cohortService.endpoints.deleteCohort.initiate>[1],
66+
) {
67+
return store.dispatch(
68+
cohortService.endpoints.deleteCohort.initiate(data, options),
69+
)
70+
}
71+
72+
export const {
73+
useCreateCohortMutation,
74+
useDeleteCohortMutation,
75+
useSyncCohortCsvMutation,
76+
// END OF EXPORTS
77+
} = cohortService
78+
79+
/* Usage examples:
80+
const [createCohort, { isLoading, data, isSuccess }] = useCreateCohortMutation()
81+
const [syncCohortCsv, { isLoading }] = useSyncCohortCsvMutation()
82+
*/

frontend/common/types/requests.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
FeatureStateValue,
66
ImportStrategy,
77
Approval,
8+
Metadata,
89
MultivariateOption,
910
SAMLConfiguration,
1011
Segment,
@@ -173,6 +174,26 @@ export type Req = {
173174
projectId: number
174175
segment: Omit<Segment, 'id' | 'uuid' | 'project'>
175176
}
177+
createCohort: {
178+
environmentApiKey: string
179+
projectId: number
180+
name: string
181+
description?: string
182+
metadata?: Metadata[]
183+
}
184+
deleteCohort: {
185+
environmentApiKey: string
186+
cohortId: number
187+
projectId: number
188+
}
189+
syncCohortCsv: {
190+
environmentApiKey: string
191+
cohortId: number
192+
projectId: number
193+
file: File
194+
identifier_column?: number
195+
has_header?: boolean
196+
}
176197
cloneSegment: {
177198
projectId: number
178199
segmentId: number

frontend/common/types/responses.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,16 @@ export type SegmentMembersResponse = PagedResponse<SegmentMember> & {
174174
// Pass as `cursor` to fetch the next page; null when there are no more rows.
175175
next_cursor: string | null
176176
}
177+
export type SegmentCohort = {
178+
id: number
179+
environment: number
180+
environment_api_key: string
181+
environment_name: string
182+
source_type: 'csv'
183+
version: number
184+
deletion_requested_at: string | null
185+
}
186+
177187
export type Segment = {
178188
id: number
179189
rules: SegmentRule[]
@@ -184,6 +194,7 @@ export type Segment = {
184194
feature?: number
185195
metadata: Metadata[] | []
186196
membership_counts?: SegmentMembership[]
197+
cohort?: SegmentCohort | null
187198
}
188199
export type ProjectChangeRequest = Omit<
189200
ChangeRequest,
@@ -995,6 +1006,29 @@ export type Metadata = {
9951006
field_value: string
9961007
}
9971008

1009+
export type Cohort = {
1010+
id: number
1011+
uuid: string
1012+
name: string
1013+
description: string | null
1014+
segment: number
1015+
source_type: 'csv'
1016+
version: number
1017+
created_at: string
1018+
}
1019+
1020+
export type CohortCsvSyncResult = {
1021+
version: number
1022+
added: number
1023+
removed: number
1024+
unchanged: number
1025+
ignored: {
1026+
empty: number
1027+
duplicates: number
1028+
too_long: number
1029+
}
1030+
}
1031+
9981032
export type MetadataFieldModelField = {
9991033
id: number
10001034
content_type: number
@@ -1324,6 +1358,8 @@ export type TrustRelationship = {
13241358
export type Res = {
13251359
segments: PagedResponse<Segment>
13261360
segment: Segment
1361+
cohort: Cohort
1362+
cohortCsvSync: CohortCsvSyncResult
13271363
segmentMembers: SegmentMembersResponse
13281364
auditLogs: PagedResponse<AuditLogItem>
13291365
organisationLicence: {}

frontend/common/utils/__tests__/csv.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { extractIdentifiers, parseCsvText, toParsedCsv } from 'common/utils/csv'
1+
import {
2+
extractIdentifiers,
3+
MAX_IDENTIFIER_BYTES,
4+
parseCsvText,
5+
toCsvColumn,
6+
toParsedCsv,
7+
} from 'common/utils/csv'
28

39
describe('parseCsvText', () => {
410
const cases: [string, string, string[][]][] = [
@@ -68,6 +74,7 @@ describe('extractIdentifiers', () => {
6874
duplicateCount: 2,
6975
emptyCount: 2,
7076
identifiers: ['a', 'b'],
77+
tooLongCount: 0,
7178
})
7279
})
7380

@@ -76,6 +83,40 @@ describe('extractIdentifiers', () => {
7683
duplicateCount: 0,
7784
emptyCount: 1,
7885
identifiers: ['y'],
86+
tooLongCount: 0,
7987
})
8088
})
89+
90+
test('identifiers over the UTF-8 byte limit are dropped', () => {
91+
// 'é' is 2 UTF-8 bytes, so 513 of them exceed 1024 bytes in 513 chars.
92+
const rows = [
93+
['a'.repeat(MAX_IDENTIFIER_BYTES)],
94+
['a'.repeat(MAX_IDENTIFIER_BYTES + 1)],
95+
['é'.repeat(513)],
96+
]
97+
expect(extractIdentifiers(rows, 0)).toEqual({
98+
duplicateCount: 0,
99+
emptyCount: 0,
100+
identifiers: ['a'.repeat(MAX_IDENTIFIER_BYTES)],
101+
tooLongCount: 2,
102+
})
103+
})
104+
})
105+
106+
describe('toCsvColumn', () => {
107+
test.each([
108+
['plain values', ['a', 'b'], 'a\nb'],
109+
['comma quoted', ['Doe, Jane', 'b'], '"Doe, Jane"\nb'],
110+
['quote escaped', ['say "hi"'], '"say ""hi"""'],
111+
['newline quoted', ['line1\nline2'], '"line1\nline2"'],
112+
])('%s', (_, values, expected) => {
113+
expect(toCsvColumn(values)).toEqual(expected)
114+
})
115+
116+
test('round-trips through parseCsvText', () => {
117+
const values = ['plain', 'Doe, Jane', 'say "hi"', 'multi\nline']
118+
expect(parseCsvText(toCsvColumn(values)).map((row) => row[0])).toEqual(
119+
values,
120+
)
121+
})
81122
})

frontend/common/utils/csv.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,13 @@ export type ExtractedIdentifiers = {
77
duplicateCount: number
88
emptyCount: number
99
identifiers: string[]
10+
tooLongCount: number
1011
}
1112

13+
// Mirrors the API's COHORT_IDENTIFIER_MAX_BYTES (Edge identifiers are
14+
// DynamoDB sort keys, capped at 1024 bytes).
15+
export const MAX_IDENTIFIER_BYTES = 1024
16+
1217
export function parseCsvText(text: string): string[][] {
1318
const rows: string[][] = []
1419
let row: string[] = []
@@ -58,7 +63,10 @@ export function toParsedCsv(
5863
if (!rawRows.length) {
5964
return { columns: [], rows: [] }
6065
}
61-
const columnCount = Math.max(...rawRows.map((cells) => cells.length))
66+
const columnCount = rawRows.reduce(
67+
(max, cells) => Math.max(max, cells.length),
68+
0,
69+
)
6270
if (hasHeaders) {
6371
const [header, ...rows] = rawRows
6472
return {
@@ -81,18 +89,30 @@ export function extractIdentifiers(
8189
): ExtractedIdentifiers {
8290
const seen = new Set<string>()
8391
const identifiers: string[] = []
92+
const encoder = new TextEncoder()
8493
let emptyCount = 0
8594
let duplicateCount = 0
95+
let tooLongCount = 0
8696
for (const cells of rows) {
8797
const value = (cells[columnIndex] ?? '').trim()
8898
if (!value) {
8999
emptyCount++
100+
} else if (encoder.encode(value).length > MAX_IDENTIFIER_BYTES) {
101+
tooLongCount++
90102
} else if (seen.has(value)) {
91103
duplicateCount++
92104
} else {
93105
seen.add(value)
94106
identifiers.push(value)
95107
}
96108
}
97-
return { duplicateCount, emptyCount, identifiers }
109+
return { duplicateCount, emptyCount, identifiers, tooLongCount }
110+
}
111+
112+
export function toCsvColumn(values: string[]): string {
113+
return values
114+
.map((value) =>
115+
/[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value,
116+
)
117+
.join('\n')
98118
}

frontend/web/components/CsvUpload/CsvUpload.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import './CsvUpload.scss'
99

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

23-
const CsvUpload: FC<CsvUploadType> = ({ onChange, rowCount, value }) => {
24+
const CsvUpload: FC<CsvUploadType> = ({
25+
maxSizeBytes,
26+
onChange,
27+
rowCount,
28+
value,
29+
}) => {
2430
const [error, setError] = useState('')
2531

2632
const onDrop = useCallback(
@@ -46,12 +52,17 @@ const CsvUpload: FC<CsvUploadType> = ({ onChange, rowCount, value }) => {
4652
accept: {
4753
'text/csv': ['.csv'],
4854
},
55+
maxSize: maxSizeBytes,
4956
multiple: false,
5057
noClick: true,
5158
noKeyboard: true,
5259
onDrop,
53-
onDropRejected: () => {
54-
setError('Please select a CSV file')
60+
onDropRejected: (rejections) => {
61+
setError(
62+
rejections[0]?.errors?.[0]?.code === 'file-too-large' && maxSizeBytes
63+
? `Please select a file smaller than ${formatFileSize(maxSizeBytes)}`
64+
: 'Please select a CSV file',
65+
)
5566
},
5667
})
5768

@@ -91,7 +102,11 @@ const CsvUpload: FC<CsvUploadType> = ({ onChange, rowCount, value }) => {
91102
</div>
92103
)}
93104
</div>
94-
{!!error && <ErrorMessage error={error} />}
105+
{!!error && (
106+
<div className='mt-3'>
107+
<ErrorMessage error={error} />
108+
</div>
109+
)}
95110
</div>
96111
)
97112
}

frontend/web/components/modals/ConfirmRemoveSegment.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import Utils from 'common/utils/utils'
66
import Button from 'components/base/forms/Button'
77
import ModalHR from './ModalHR'
88
import { deleteSegment } from 'common/services/useSegment'
9+
import { deleteCohort } from 'common/services/useCohort'
910
import { getStore } from 'common/store'
1011

1112
type ConfirmRemoveSegmentType = {
@@ -19,7 +20,15 @@ export const handleRemoveSegment = (
1920
) => {
2021
const removeSegmentCallback = async () => {
2122
try {
22-
const res = await deleteSegment(getStore(), { id: segment.id, projectId })
23+
// Cohort-managed segments must be deleted via their cohort; the segment
24+
// endpoint rejects them.
25+
const res = segment.cohort
26+
? await deleteCohort(getStore(), {
27+
cohortId: segment.cohort.id,
28+
environmentApiKey: segment.cohort.environment_api_key,
29+
projectId: Number(projectId),
30+
})
31+
: await deleteSegment(getStore(), { id: segment.id, projectId })
2332
if (res.error) throw new Error(res.error)
2433
toast(
2534
<div>

0 commit comments

Comments
 (0)