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
6 changes: 6 additions & 0 deletions frontend/common/types/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ export type SegmentRule = {
conditions: SegmentCondition[]
version_of: number | undefined
}
export type SegmentMembership = {
environment: number
count: number
last_synced_at: string
}
export type Segment = {
id: number
rules: SegmentRule[]
Expand All @@ -169,6 +174,7 @@ export type Segment = {
project: string | number
feature?: number
metadata: Metadata[] | []
memberships?: SegmentMembership[]
}
export type ProjectChangeRequest = Omit<
ChangeRequest,
Expand Down
119 changes: 119 additions & 0 deletions frontend/e2e/tests/segment-membership-test.pw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { test, expect } from '../test-setup'
import { log, createHelpers } from '../helpers'
import { E2E_USER, PASSWORD, E2E_SEGMENT_PROJECT_1 } from '../config'

const TEST_SEGMENT = 'segment_membership_badge'
const ENV_COUNTS = [42, 17]

type Env = { id: number; name: string; api_key?: string }

test('Segment membership badges render in list, tab, and env select @oss', async ({
page,
}) => {
const {
createSegment,
deleteSegment,
gotoProject,
gotoSegments,
login,
waitForElementVisible,
} = createHelpers(page)

const envs: Env[] = []

await page.route(/\/projects\/\d+\/environments\/\?/, async (route) => {
const response = await route.fetch()
const body = await response.json()
if (!envs.length && Array.isArray(body?.results)) {
body.results.slice(0, ENV_COUNTS.length).forEach((e: Env) => {
envs.push({ id: e.id, name: e.name, api_key: e.api_key })
})
}
await route.fulfill({ response, json: body })
})

const memberships = () =>
envs.slice(0, ENV_COUNTS.length).map((e, i) => ({
environment: e.id,
count: ENV_COUNTS[i],
last_synced_at: new Date().toISOString(),
}))

await page.route(/\/projects\/\d+\/segments\/\?/, async (route) => {
const response = await route.fetch()
const body = await response.json()
if (envs.length && Array.isArray(body?.results) && body.results.length) {
const target =
body.results.find((s: { name: string }) => s.name === TEST_SEGMENT) ??
body.results[0]
target.memberships = memberships()
}
await route.fulfill({ response, json: body })
})

await page.route(/\/projects\/\d+\/segments\/\d+\/?(?:\?|$)/, async (route) => {
const response = await route.fetch()
const body = await response.json()
if (envs.length && body && typeof body === 'object') {
body.memberships = memberships()
}
await route.fulfill({ response, json: body })
})

log('Login and create segment')
await login(E2E_USER, PASSWORD)
await gotoProject(E2E_SEGMENT_PROJECT_1)
await waitForElementVisible('#features-page')
await gotoSegments()
await createSegment(TEST_SEGMENT, [
{ name: 'plan', operator: 'EQUAL', value: 'growth' },
])

log('Reload segments list with mocked memberships')
await gotoSegments()

if (!envs.length) {
throw new Error('Expected to capture project environments via route mock')
}

log('Assert total badge renders with sum across envs')
const total = ENV_COUNTS.reduce((a, b) => a + b, 0)
const totalBadge = page
.locator('[data-test="segment-membership-total"]')
.filter({ hasText: `${total}` })
await expect(totalBadge.first()).toBeVisible()

log('Open segment edit page')
await page.getByText(TEST_SEGMENT).first().click()

log('Switch to Identities tab — total badge sits next to label')
await page.getByRole('button', { name: /Identities/ }).click()
await expect(
page
.getByRole('button', { name: /Identities/ })
.locator('[data-test="segment-membership-total"]'),
).toBeVisible()

log('Open environment select and assert per-env badge')
const select = page.locator('.react-select__control').first()
await select.click()
for (const env of envs.slice(0, ENV_COUNTS.length)) {
await expect(
page.locator(`[data-test="segment-membership-${env.api_key ?? ''}"]`).or(
page.locator('.react-select__option').filter({ hasText: env.name }),
),
).toHaveCount(1, { timeout: 5_000 })
}

log('Select first env — full timestamp appears below the select')
await page
.locator('.react-select__option')
.filter({ hasText: envs[0].name })
.click()
await expect(page.getByText(/Last synced:/)).toBeVisible()

log('Clean up test segment')
await page.goBack()
await gotoSegments()
await deleteSegment(TEST_SEGMENT)
})
15 changes: 14 additions & 1 deletion frontend/web/components/modals/CreateSegment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
} from 'common/services/useSegment'
import Utils from 'common/utils/utils'
import AssociatedSegmentOverrides from 'components/segments/AssociatedSegmentOverrides'
import { SegmentMembershipTotalBadge } from 'components/segments/SegmentMembershipBadge'
import Button from 'components/base/forms/Button'
import InfoMessage from 'components/InfoMessage'
import InputGroup from 'components/base/forms/InputGroup'
Expand Down Expand Up @@ -582,7 +583,18 @@ const CreateSegment: FC<CreateSegmentType> = ({
/>
</div>
</TabItem>
<TabItem tabLabel='Identities'>
<TabItem
tabLabelString='Identities'
tabLabel={
<>
Identities
<SegmentMembershipTotalBadge
compact
memberships={segment.memberships}
/>
</>
}
>
<div className='my-4'>
<CreateSegmentUsersTabContent
projectId={projectId}
Expand All @@ -595,6 +607,7 @@ const CreateSegment: FC<CreateSegmentType> = ({
name={name}
searchInput={searchInput}
setSearchInput={setSearchInput}
memberships={segment.memberships}
/>
</div>
</TabItem>
Expand Down
69 changes: 61 additions & 8 deletions frontend/web/components/modals/CreateSegmentUsersTabContent.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import React, { FC } from 'react'
import moment from 'moment'
import EnvironmentSelect from 'components/EnvironmentSelect'
import PanelSearch from 'components/PanelSearch'
import InfoMessage from 'components/InfoMessage'
import InputGroup from 'components/base/forms/InputGroup'
import Utils from 'common/utils/utils'
import { Res } from 'common/types/responses'
import { Environment, Res, SegmentMembership } from 'common/types/responses'
import Icon from 'components/icons/Icon'
import {
identitySegmentService,
useGetIdentitySegmentsQuery,
} from 'common/services/useIdentitySegment'
import { getStore } from 'common/store'
import ProjectStore from 'common/stores/project-store'
import { SegmentMembershipEnvBadge } from 'components/segments/SegmentMembershipBadge'

interface CreateSegmentUsersTabContentProps {
projectId: string | number
Expand All @@ -23,6 +26,13 @@ interface CreateSegmentUsersTabContentProps {
name: string
searchInput: string
setSearchInput: (input: string) => void
memberships?: SegmentMembership[]
}

type EnvOption = {
value: string
label: string
environment: Environment
}

type UserRowType = {
Expand Down Expand Up @@ -80,6 +90,7 @@ const CreateSegmentUsersTabContent: React.FC<
environmentId,
identities,
identitiesLoading,
memberships,
name,
page,
projectId,
Expand All @@ -88,6 +99,37 @@ const CreateSegmentUsersTabContent: React.FC<
setPage,
setSearchInput,
}) => {
const membershipByEnvId = React.useMemo(() => {
const map = new Map<number, SegmentMembership>()
;(memberships ?? []).forEach((m) => map.set(m.environment, m))
return map
}, [memberships])

const renderEnvOption = (data: unknown) => {
const { environment, label } = data as Partial<EnvOption>
const membership = environment
? membershipByEnvId.get(environment.id)
: undefined
return (
<span className='d-flex align-items-center'>
<span>{label}</span>
{environment && membership && (
<SegmentMembershipEnvBadge
membership={membership}
environment={environment}
/>
)}
</span>
)
}

const selectedMembership = React.useMemo(() => {
if (!environmentId) return null
const envs = (ProjectStore.getEnvs() as Environment[] | null) || []
const env = envs.find((e) => e.api_key === environmentId)
return env ? membershipByEnvId.get(env.id) ?? null : null
}, [environmentId, membershipByEnvId])

return (
<>
<InfoMessage collapseId={'random-identity-sample'}>
Expand All @@ -100,13 +142,24 @@ const CreateSegmentUsersTabContent: React.FC<
title='Environment'
className='col-4'
component={
<EnvironmentSelect
projectId={`${projectId}`}
value={environmentId}
onChange={(environmentId: string) => {
setEnvironmentId(environmentId)
}}
/>
<>
<EnvironmentSelect
projectId={`${projectId}`}
value={environmentId}
onChange={(environmentId: string) => {
setEnvironmentId(environmentId)
}}
formatOptionLabel={renderEnvOption}
/>
<div className='text-muted fs-small mt-2'>
Last synced:{' '}
{selectedMembership
? moment(selectedMembership.last_synced_at).format(
'Do MMM YYYY HH:mm:ss',
)
: '—'}
</div>
</>
}
/>
<PanelSearch
Expand Down
89 changes: 89 additions & 0 deletions frontend/web/components/segments/SegmentMembershipBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React, { FC } from 'react'

import { Environment, SegmentMembership } from 'common/types/responses'
import ProjectStore from 'common/stores/project-store'
import UsersIcon from 'components/icons/UsersIcon'

const shortAgo = (iso: string): string => {
const diffSec = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000))
if (diffSec < 60) return `${diffSec}s ago`
const diffMin = Math.round(diffSec / 60)
if (diffMin < 60) return `${diffMin}m ago`
const diffHr = Math.round(diffMin / 60)
if (diffHr < 24) return `${diffHr}h ago`
return `${Math.round(diffHr / 24)}d ago`
}

type ChipProps = {
count: number
ago?: string
dataTest?: string
compact?: boolean
}

const Chip: FC<ChipProps> = ({ ago, compact, count, dataTest }) => {
const noun = count === 1 ? 'identity' : 'identities'
return (
<span
className='chip chip--xs bg-primary text-white ms-3'
style={{ border: 'none', alignSelf: 'center', verticalAlign: 'middle' }}
data-test={dataTest}
>
<UsersIcon className='chip-svg-icon' />
<span>
{count}
{compact ? '' : ` ${noun}`}
{ago ? ` ~${ago}` : ''}
</span>
</span>
)
}

type TotalProps = {
memberships: SegmentMembership[] | undefined
compact?: boolean
}

export const SegmentMembershipTotalBadge: FC<TotalProps> = ({
compact,
memberships,
}) => {
if (!memberships?.length) {
return null
}
const total = memberships.reduce((sum, m) => sum + m.count, 0)
const latest = memberships.reduce(
(acc, m) => (!acc || m.last_synced_at > acc ? m.last_synced_at : acc),
'',
)
return (
<Chip
compact={compact}
count={total}
ago={latest ? shortAgo(latest) : undefined}
dataTest='segment-membership-total'
/>
)
}

type EnvProps = {
membership: SegmentMembership
environment?: Environment
}

export const SegmentMembershipEnvBadge: FC<EnvProps> = ({
environment,
membership,
}) => {
const envs = (ProjectStore.getEnvs() as Environment[] | null) || []
const env = environment ?? envs.find((e) => e.id === membership.environment)
if (!env) {
return null
}
return (
<Chip
count={membership.count}
dataTest={`segment-membership-${env.api_key}`}
/>
)
}
2 changes: 2 additions & 0 deletions frontend/web/components/segments/SegmentRow/SegmentRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useHasPermission } from 'common/providers/Permission'

import { Segment } from 'common/types/responses'
import SegmentAction from './components/SegmentAction'
import { SegmentMembershipTotalBadge } from 'components/segments/SegmentMembershipBadge'
import ConfirmCloneSegment from 'components/modals/ConfirmCloneSegment'
import { useCloneSegmentMutation } from 'common/services/useSegment'
import { handleRemoveSegment } from 'components/modals/ConfirmRemoveSegment'
Expand Down Expand Up @@ -81,6 +82,7 @@ const SegmentRow: FC<SegmentRowProps> = ({ index, projectId, segment }) => {
{feature && (
<div className='chip chip--xs ml-2'>Feature-Specific</div>
)}
<SegmentMembershipTotalBadge memberships={segment.memberships} />
</Row>
<div className='list-item-subtitle mt-1'>
{description || 'No description'}
Expand Down
Loading
Loading