Skip to content
Merged
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
48 changes: 24 additions & 24 deletions frontend/common/types/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
StageActionBody,
ChangeRequest,
ExpectedDirection,
ExperimentAudienceMatch,
ExperimentStatus,
MetricAggregation,
MetricDirection,
Expand Down Expand Up @@ -154,6 +155,27 @@ export interface PipelineStageRequest {

type WarehouseConfigValue = string | number | boolean

// Omitting `audience` leaves the stored audience untouched; an empty
// `segment_ids` clears it back to every identity in the environment.
export type ExperimentAudienceBody = {
match: ExperimentAudienceMatch
segment_ids: number[]
}

export type ExperimentRolloutBody = {
enabled: boolean
rollout_percentage: number
feature_state_value: {
type: 'integer' | 'string' | 'boolean'
value: string
}
multivariate_feature_state_values: {
multivariate_feature_option: number
percentage_allocation: number
}[]
audience?: ExperimentAudienceBody
}

export type Req = {
getFeatureCodeReferences: {
projectId: number
Expand Down Expand Up @@ -1141,18 +1163,7 @@ export type Req = {
hypothesis: string
feature: number
metrics: { metric: number; expected_direction: ExpectedDirection }[]
experiment_rollout: {
enabled: boolean
rollout_percentage: number
feature_state_value: {
type: 'integer' | 'string' | 'boolean'
value: string
}
multivariate_feature_state_values: {
multivariate_feature_option: number
percentage_allocation: number
}[]
}
experiment_rollout: ExperimentRolloutBody
}
}
experimentAction: { environmentId: string; experimentId: number }
Expand All @@ -1164,18 +1175,7 @@ export type Req = {
updateExperimentRollout: {
environmentId: string
experimentId: number
body: {
enabled: boolean
rollout_percentage: number
feature_state_value: {
type: 'integer' | 'string' | 'boolean'
value: string
}
multivariate_feature_state_values: {
multivariate_feature_option: number
percentage_allocation: number
}[]
}
body: ExperimentRolloutBody
}
deleteExperiment: { environmentId: string; experimentId: number }
getExperiment: { environmentId: string; experimentId: number }
Expand Down
18 changes: 18 additions & 0 deletions frontend/common/types/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,23 @@ export type Experiment = {
experiment_rollout?: ExperimentRollout
}

export type ExperimentAudienceMatch = 'any' | 'all'

// Provenance only: the rules are a frozen copy on the rollout segment, so a
// source segment can be deleted while the experiment keeps evaluating it.
export type ExperimentAudienceSegment = {
id: number
name: string
is_cohort: boolean
cohort_source_type: CohortSourceType | null
deleted: boolean
}

export type ExperimentAudience = {
match: ExperimentAudienceMatch
segments: ExperimentAudienceSegment[]
}

export type ExperimentRollout = {
enabled: boolean
rollout_percentage: number
Expand All @@ -688,6 +705,7 @@ export type ExperimentRollout = {
multivariate_feature_option: number
percentage_allocation: number
}[]
audience?: ExperimentAudience
}

export type ExpectedDirection =
Expand Down
72 changes: 51 additions & 21 deletions frontend/web/components/SegmentSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { FC } from 'react'
import { Res, Segment } from 'common/types/responses'
import { Res, Segment, SegmentMembership } from 'common/types/responses'
import { useGetSegmentsQuery } from 'common/services/useSegment'
import useInfiniteScroll from 'common/useInfiniteScroll'
import { Req } from 'common/types/requests'
Expand All @@ -9,26 +9,30 @@ import Button from './base/forms/Button'
import Chip from './base/Chip'

type SegmentSelectType = {
disabled: boolean
disabled?: boolean
projectId: string
'data-test'?: string
placeholder?: string
className?: string
value: number | string | undefined
onChange: (value: any) => void
filter?: (segments: Segment) => Segment[]
filter?: (segment: Segment) => boolean
// When set, options display the segment's membership count for this
// environment (database id, not api key), when the backend has computed one.
membershipCountEnvironmentId?: number
}

const SegmentSelect: FC<SegmentSelectType> = ({
className,
filter,
membershipCountEnvironmentId,
projectId,
...rest
}) => {
const { data, isLoading, loadMore, searchItems } = useInfiniteScroll<
Req['getSegments'],
Res['segments']
>(useGetSegmentsQuery, { page_size: 100, projectId })
>(useGetSegmentsQuery, { page_size: 100, projectId: Number(projectId) })

let filteredResults: Res['segments']['results'] = []
if (data) {
Expand All @@ -37,16 +41,23 @@ const SegmentSelect: FC<SegmentSelectType> = ({
(segment) => !segment.cohort?.deletion_requested_at,
)
if (filter) {
filteredResults = filteredResults.filter(
filter,
) as Res['segments']['results']
filteredResults = filteredResults.filter(filter)
}
}
const options = filteredResults.map(
({ cohort, feature, id: value, name: label }) => ({
({
cohort,
description,
feature,
id: value,
membership_counts,
name: label,
}) => ({
cohort,
description,
feature,
label,
membership_counts,
value,
}),
)
Expand Down Expand Up @@ -86,19 +97,38 @@ const SegmentSelect: FC<SegmentSelectType> = ({
</components.Menu>
)
},
Option: ({ children, data, innerProps, innerRef }: any) => (
<div ref={innerRef} {...innerProps} className='react-select__option'>
{children}
{!!data.feature && (
<div className='unread ml-2 px-2'>Feature-Specific</div>
)}
{!!data.cohort && (
<Chip className='ml-2' size='xs' variant='accent'>
{data.cohort.source_type.toUpperCase()}
</Chip>
)}
</div>
),
Option: ({ children, data, innerProps, innerRef }: any) => {
const membershipCount =
membershipCountEnvironmentId === undefined
? undefined
: data.membership_counts?.find(
(membership: SegmentMembership) =>
membership.environment === membershipCountEnvironmentId,
)?.count
return (
<div
ref={innerRef}
{...innerProps}
className='react-select__option d-flex align-items-center'
>
{children}
{!!data.feature && (
<div className='unread ml-2 px-2'>Feature-Specific</div>
)}
{!!data.cohort && (
<Chip className='ml-2' size='xs' variant='accent'>
{data.cohort.source_type.toUpperCase()}
</Chip>
)}
{typeof membershipCount === 'number' && (
<span className='ml-auto text-muted fs-caption text-nowrap'>
{membershipCount.toLocaleString()}{' '}
{membershipCount === 1 ? 'identity' : 'identities'}
</span>
)}
</div>
)
},
}}
options={options}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { FC, useCallback } from 'react'
import {
ExperimentAudienceMatch,
Segment,
SegmentCohort,
SegmentMembership,
} from 'common/types/responses'
import { useProjectEnvironments } from 'common/hooks/useProjectEnvironments'
import InlinePillToggle from 'components/base/forms/InlinePillToggle'
import SegmentSelect from 'components/SegmentSelect'
import AudienceSegmentList from 'components/experiments/AudienceSegmentList'
import {
AudienceSegment,
MAX_AUDIENCE_SEGMENTS,
} from 'components/experiments/rollout'
import { isSelectableAudienceSegment } from './utils'
Comment thread
Zaimwa9 marked this conversation as resolved.

type SegmentOption = {
value: number
label: string
cohort?: SegmentCohort | null
description?: string
membership_counts?: SegmentMembership[]
}

type AudiencePickerProps = {
projectId: number
environmentId: string
segments: AudienceSegment[]
match: ExperimentAudienceMatch
onSegmentsChange: (segments: AudienceSegment[]) => void
onMatchChange: (match: ExperimentAudienceMatch) => void
}

const AudiencePicker: FC<AudiencePickerProps> = ({
Comment thread
Zaimwa9 marked this conversation as resolved.
environmentId,
match,
onMatchChange,
onSegmentsChange,
projectId,
segments,
}) => {
const isAtCap = segments.length >= MAX_AUDIENCE_SEGMENTS

const { getEnvironmentIdFromKey } = useProjectEnvironments(projectId)
const environmentDbId = getEnvironmentIdFromKey(environmentId)

const handleSelect = useCallback(
(option: SegmentOption | null) => {
if (!option) return
onSegmentsChange([
...segments,
{
cohort: option.cohort,
description: option.description,
id: option.value,
membershipCount: option.membership_counts?.find(
(membership) => membership.environment === environmentDbId,
)?.count,
name: option.label,
},
])
},
[environmentDbId, onSegmentsChange, segments],
)

const handleRemove = useCallback(
(id: number) => {
onSegmentsChange(segments.filter((segment) => segment.id !== id))
},
[onSegmentsChange, segments],
)

return (
<div className='d-flex flex-column gap-3'>
{segments.length ? (
<AudienceSegmentList
segments={segments.map((segment) => ({
cohortSourceType: segment.cohort?.source_type,
description: segment.description,
id: segment.id,
membershipCount: segment.membershipCount,
name: segment.name,
}))}
onRemove={handleRemove}
/>
) : (
<p className='text-muted mb-0'>
All identities in this environment are eligible. Add a segment to
narrow the audience.
</p>
)}

{segments.length > 1 && (
<div className='d-flex align-items-center gap-2'>
<span className='text-muted'>Enter the experiment when matching</span>
<InlinePillToggle
data-test='experiment-audience-match'
size='small'
options={[
{ label: 'ANY', value: 'any' },
{ label: 'ALL', value: 'all' },
]}
value={match}
onChange={onMatchChange}
/>
<span className='text-muted'>of these segments</span>
</div>
)}

{!isAtCap && (
<SegmentSelect
className='w-100'
data-test='experiment-audience-segment-select'
membershipCountEnvironmentId={environmentDbId}
projectId={String(projectId)}
placeholder='Add a segment...'
value={undefined}
filter={(segment: Segment) =>
isSelectableAudienceSegment(segment, environmentId, segments)
}
onChange={handleSelect}
/>
)}

{isAtCap && MAX_AUDIENCE_SEGMENTS > 1 && (
<span className='fs-caption text-muted'>
An experiment can target up to {MAX_AUDIENCE_SEGMENTS} segments.
</span>
)}
</div>
)
}

export default AudiencePicker
Loading
Loading