Skip to content

Commit 9dd6141

Browse files
authored
feat: cohort synchronisation keys and provider connect modals (#8420)
1 parent cf98e07 commit 9dd6141

20 files changed

Lines changed: 1025 additions & 3 deletions

‎frontend/common/constants.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,8 @@ const Constants = {
198198
'React Native': 'javascript',
199199
},
200200
},
201+
cohortSyncKeyPermissions:
202+
'To manage cohort synchronisation keys you need the <i>Manage segment overrides</i> permission for this environment and the <i>Manage segments</i> permission for this project.<br/>Please contact an administrator.',
201203
colours: {
202204
primary: '#6837fc',
203205
white: '#ffffff',

‎frontend/common/services/useCohort.ts‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { service } from 'common/service'
44
import toFormData from 'common/utils/toFormData'
55

66
export const cohortService = service
7-
.enhanceEndpoints({ addTagTypes: ['Cohort', 'Segment'] })
7+
.enhanceEndpoints({ addTagTypes: ['Cohort', 'CohortSyncKey', 'Segment'] })
88
.injectEndpoints({
99
endpoints: (builder) => ({
1010
createCohort: builder.mutation<Res['cohort'], Req['createCohort']>({
@@ -22,6 +22,17 @@ export const cohortService = service
2222
url: `environments/${query.environmentApiKey}/cohorts/`,
2323
}),
2424
}),
25+
createCohortSyncKey: builder.mutation<
26+
Res['cohortSyncKeyCreated'],
27+
Req['createCohortSyncKey']
28+
>({
29+
invalidatesTags: [{ id: 'LIST', type: 'CohortSyncKey' }],
30+
query: (query) => ({
31+
body: { name: query.name },
32+
method: 'POST',
33+
url: `environments/${query.environmentApiKey}/cohorts/sync-keys/`,
34+
}),
35+
}),
2536
deleteCohort: builder.mutation<void, Req['deleteCohort']>({
2637
invalidatesTags: (q, e, arg) => [
2738
{ id: 'LIST', type: 'Cohort' },
@@ -38,6 +49,22 @@ export const cohortService = service
3849
url: `environments/${query.environmentApiKey}/cohorts/${query.cohortId}/`,
3950
}),
4051
}),
52+
getCohortSyncKeys: builder.query<
53+
Res['cohortSyncKeys'],
54+
Req['getCohortSyncKeys']
55+
>({
56+
providesTags: [{ id: 'LIST', type: 'CohortSyncKey' }],
57+
query: (query) => ({
58+
url: `environments/${query.environmentApiKey}/cohorts/sync-keys/`,
59+
}),
60+
}),
61+
revokeCohortSyncKey: builder.mutation<void, Req['revokeCohortSyncKey']>({
62+
invalidatesTags: [{ id: 'LIST', type: 'CohortSyncKey' }],
63+
query: (query) => ({
64+
method: 'DELETE',
65+
url: `environments/${query.environmentApiKey}/cohorts/sync-keys/${query.prefix}/`,
66+
}),
67+
}),
4168
syncCohortCsv: builder.mutation<
4269
Res['cohortCsvSync'],
4370
Req['syncCohortCsv']
@@ -95,8 +122,11 @@ export async function deleteCohort(
95122

96123
export const {
97124
useCreateCohortMutation,
125+
useCreateCohortSyncKeyMutation,
98126
useDeleteCohortMutation,
99127
useGetCohortQuery,
128+
useGetCohortSyncKeysQuery,
129+
useRevokeCohortSyncKeyMutation,
100130
useSyncCohortCsvMutation,
101131
useUpdateCohortMutation,
102132
// END OF EXPORTS

‎frontend/common/types/requests.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,17 @@ export type Req = {
207207
identifier_column?: number
208208
has_header?: boolean
209209
}
210+
getCohortSyncKeys: {
211+
environmentApiKey: string
212+
}
213+
createCohortSyncKey: {
214+
environmentApiKey: string
215+
name: string
216+
}
217+
revokeCohortSyncKey: {
218+
environmentApiKey: string
219+
prefix: string
220+
}
210221
cloneSegment: {
211222
projectId: number
212223
segmentId: number

‎frontend/common/types/responses.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,6 +1037,16 @@ export type Cohort = {
10371037
membership_counts: CohortMembershipCounts
10381038
}
10391039

1040+
export type CohortSyncKey = {
1041+
prefix: string
1042+
name: string
1043+
created: string
1044+
key: string | null
1045+
}
1046+
1047+
// The plaintext key only exists in the create response.
1048+
export type CohortSyncKeyCreated = CohortSyncKey & { key: string }
1049+
10401050
export type CohortCsvSyncResult = {
10411051
version: number
10421052
added: number
@@ -1379,6 +1389,8 @@ export type Res = {
13791389
segments: PagedResponse<Segment>
13801390
segment: Segment
13811391
cohort: Cohort
1392+
cohortSyncKeys: CohortSyncKey[]
1393+
cohortSyncKeyCreated: CohortSyncKeyCreated
13821394
cohortCsvSync: CohortCsvSyncResult
13831395
segmentMembers: SegmentMembersResponse
13841396
auditLogs: PagedResponse<AuditLogItem>
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { test, expect } from '../test-setup'
2+
import {
3+
byId,
4+
log,
5+
createHelpers,
6+
getFlagsmith,
7+
LONG_TIMEOUT,
8+
} from '../helpers'
9+
import { E2E_USER, PASSWORD, E2E_TEST_PROJECT } from '../config'
10+
11+
const COHORT_PROVIDERS = ['amplitude', 'mixpanel'] as const
12+
13+
type CohortProvider = (typeof COHORT_PROVIDERS)[number]
14+
15+
type SegmentSourceFlagEntry = {
16+
active?: boolean
17+
name?: string
18+
visible?: boolean
19+
}
20+
21+
// Mirrors `getSegmentSources` in CreateSegmentSourcesModal: only an entry that
22+
// is visible AND active opens the connect modal (and shows the environment tab).
23+
const getActiveCohortProviders = (config: unknown): CohortProvider[] => {
24+
if (!Array.isArray(config)) {
25+
return []
26+
}
27+
return (config as SegmentSourceFlagEntry[])
28+
.filter(
29+
(entry) =>
30+
COHORT_PROVIDERS.includes(entry?.name as CohortProvider) &&
31+
entry?.visible !== false &&
32+
entry?.active === true,
33+
)
34+
.map((entry) => entry.name as CohortProvider)
35+
}
36+
37+
test.describe('Cohort Synchronisation Keys Tests', () => {
38+
test('Cohort synchronisation keys can be created from a provider connection and revoked in Environment Settings @oss', async ({
39+
page,
40+
}) => {
41+
const {
42+
click,
43+
gotoProject,
44+
gotoSegments,
45+
login,
46+
setText,
47+
waitForElementVisible,
48+
waitForModalToClose,
49+
} = createHelpers(page)
50+
51+
const flagsmith = await getFlagsmith()
52+
const activeProviders = flagsmith.hasFeature(
53+
'create_segment_with_external_sources',
54+
)
55+
? getActiveCohortProviders(
56+
flagsmith.getValue('create_segment_with_external_sources', {
57+
fallback: null,
58+
json: true,
59+
}),
60+
)
61+
: []
62+
test.skip(
63+
activeProviders.length === 0,
64+
'No active Amplitude or Mixpanel entry in `create_segment_with_external_sources`, so the cohort synchronisation UI is unreachable',
65+
)
66+
67+
// With both providers active each key goes through a different provider;
68+
// with one, the second key goes through its "Create a new key" state.
69+
const firstProvider = activeProviders[0]
70+
const secondProvider = activeProviders[1] ?? activeProviders[0]
71+
72+
const runId = Date.now()
73+
const keyOne = `e2e key one ${runId}`
74+
const keyTwo = `e2e key two ${runId}`
75+
const keyThree = `e2e key three ${runId}`
76+
77+
const connectModal = page.locator('.connect-cohort-provider')
78+
const envSelect = connectModal.locator(byId('connect-provider-env-select'))
79+
80+
const openConnectModal = async (provider: CohortProvider) => {
81+
await click(byId('show-create-segment-btn'))
82+
await click(byId(`segment-source-${provider}`))
83+
await waitForElementVisible(byId('connect-provider-done'))
84+
await expect(envSelect).toBeVisible({ timeout: LONG_TIMEOUT })
85+
// The modal defaults to the alphabetically first environment; assert it
86+
// resolved so both keys are created against the same environment.
87+
const label = (await envSelect.innerText()).trim()
88+
expect(label).not.toBe('')
89+
expect(label).not.toBe('Select an Environment')
90+
return label
91+
}
92+
93+
const createKeyInConnectModal = async (name: string) => {
94+
// Step 1 renders either the create form or the existing-keys state,
95+
// depending on whether this environment already has keys.
96+
await connectModal
97+
.locator(
98+
`${byId('connect-provider-key-name')}, ${byId(
99+
'connect-provider-new-key',
100+
)}`,
101+
)
102+
.first()
103+
.waitFor({ state: 'visible', timeout: LONG_TIMEOUT })
104+
const newKeyButton = connectModal.locator(
105+
byId('connect-provider-new-key'),
106+
)
107+
if (await newKeyButton.isVisible()) {
108+
await click(byId('connect-provider-new-key'))
109+
}
110+
await setText(byId('connect-provider-key-name'), name)
111+
await click(byId('connect-provider-create-key'))
112+
await expect(
113+
connectModal.locator(byId('connect-provider-key-value')),
114+
).toHaveValue(/.+/, { timeout: LONG_TIMEOUT })
115+
await click(byId('connect-provider-done'))
116+
await waitForModalToClose()
117+
}
118+
119+
log('Login')
120+
await login(E2E_USER, PASSWORD)
121+
await gotoProject(E2E_TEST_PROJECT)
122+
await gotoSegments()
123+
124+
log(`Create the first key while connecting ${firstProvider}`)
125+
const environmentLabel = await openConnectModal(firstProvider)
126+
await createKeyInConnectModal(keyOne)
127+
128+
log(`Create the second key while connecting ${secondProvider}`)
129+
expect(await openConnectModal(secondProvider)).toBe(environmentLabel)
130+
await waitForElementVisible(byId('connect-provider-new-key'))
131+
await expect(connectModal).toContainText(keyOne)
132+
// This link carries the environment the modal is working against, so
133+
// following it guarantees we inspect the keys we just created.
134+
const settingsLink = connectModal.locator(
135+
'a[href*="tab=cohorts"]',
136+
)
137+
await expect(settingsLink).toBeVisible()
138+
const settingsHref = (await settingsLink.getAttribute('href')) ?? ''
139+
expect(settingsHref).not.toBe('')
140+
await createKeyInConnectModal(keyTwo)
141+
142+
log('Open the Cohort Synchronisation tab in Environment Settings')
143+
await page.goto(settingsHref)
144+
await waitForElementVisible('#cohort-sync-keys-list')
145+
const keysList = page.locator('#cohort-sync-keys-list')
146+
await expect(keysList).toContainText(keyOne)
147+
await expect(keysList).toContainText(keyTwo)
148+
149+
log('Create a third key from Environment Settings')
150+
await click(byId('create-cohort-sync-key'))
151+
await setText(byId('cohort-sync-key-name'), keyThree)
152+
await click(byId('cohort-sync-key-create'))
153+
await expect(page.locator(byId('cohort-sync-key-value'))).toHaveValue(
154+
/.+/,
155+
{ timeout: LONG_TIMEOUT },
156+
)
157+
await click(byId('cohort-sync-key-done'))
158+
await waitForModalToClose()
159+
await expect(keysList).toContainText(keyThree)
160+
161+
log('Revoke the keys created by this test')
162+
for (const name of [keyOne, keyTwo, keyThree]) {
163+
const row = keysList.locator('.list-item').filter({ hasText: name })
164+
await row.locator('[aria-label^="Revoke "]').click()
165+
await click('#confirm-btn-yes')
166+
await expect(row).toHaveCount(0, { timeout: LONG_TIMEOUT })
167+
}
168+
169+
log('Verify the test keys are gone')
170+
for (const name of [keyOne, keyTwo, keyThree]) {
171+
await expect(page.getByText(name, { exact: true })).toHaveCount(0)
172+
}
173+
})
174+
})

‎frontend/web/components/PanelSearch.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,12 @@ const PanelSearch = <T,>(props: PanelSearchProps<T>): ReactElement => {
319319
</Row>
320320
)}
321321
{onRefresh && (
322-
<Button theme='text' size='xSmall' onClick={onRefresh}>
322+
<Button
323+
theme='text'
324+
size='xSmall'
325+
className='mr-2'
326+
onClick={onRefresh}
327+
>
323328
<Icon name='refresh' fill='#6837FC' width={16} /> Refresh
324329
</Button>
325330
)}

‎frontend/web/components/SegmentOverrides.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,12 @@ class TheComponent extends Component {
739739
const filter = (segment) => {
740740
if (segment.feature && segment.feature !== this.props.feature)
741741
return false
742+
// A cohort-managed segment only has members in its own environment.
743+
if (
744+
segment.cohort &&
745+
segment.cohort.environment_api_key !== this.props.environmentId
746+
)
747+
return false
742748
if (this.props.id && this.props.id !== segment.id) return null
743749
const foundSegment = find(value, (v) => v.segment === segment.id)
744750
return !value || !foundSegment || (foundSegment && foundSegment.toRemove)

0 commit comments

Comments
 (0)