Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: split testing UI #5093

Merged
merged 20 commits into from
Mar 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
50 changes: 50 additions & 0 deletions frontend/common/services/useConversionEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Res } from 'common/types/responses'
import { Req } from 'common/types/requests'
import { service } from 'common/service'
import Utils from 'common/utils/utils'

export const conversionEventService = service
.enhanceEndpoints({ addTagTypes: ['ConversionEvent'] })
.injectEndpoints({
endpoints: (builder) => ({
getConversionEvents: builder.query<
Res['conversionEvents'],
Req['getConversionEvents']
>({
providesTags: [{ id: 'LIST', type: 'ConversionEvent' }],
query: (query) => {
return {
url: `conversion-event-types/?${Utils.toParam(query)}`,
}
},
}),
// END OF ENDPOINTS
}),
})

export async function getConversionEvents(
store: any,
data: Req['getConversionEvents'],
options?: Parameters<
typeof conversionEventService.endpoints.getConversionEvents.initiate
>[1],
) {
return store.dispatch(
conversionEventService.endpoints.getConversionEvents.initiate(
data,
options,
),
)
}
// END OF FUNCTION_EXPORTS

export const {
useGetConversionEventsQuery,
// END OF EXPORTS
} = conversionEventService

/* Usage examples:
const { data, isLoading } = useGetConversionEventsQuery({ id: 2 }, {}) //get hook
const [createConversionEvents, { isLoading, data, isSuccess }] = useCreateConversionEventsMutation() //create hook
conversionEventService.endpoints.getConversionEvents.select({id: 2})(store.getState()) //access data from any function
*/
114 changes: 114 additions & 0 deletions frontend/common/services/useSplitTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import {
PagedResponse,
PConfidence,
Res,
ServersideSplitTestResult,
SplitTestResult,
} from 'common/types/responses'
import { Req } from 'common/types/requests'
import { service } from 'common/service'
import Utils from 'common/utils/utils'
import { groupBy, sortBy } from 'lodash'

export const splitTestService = service
.enhanceEndpoints({ addTagTypes: ['SplitTest'] })
.injectEndpoints({
endpoints: (builder) => ({
getSplitTest: builder.query<Res['splitTest'], Req['getSplitTest']>({
providesTags: (res, _, q) => [
{ id: q?.conversion_event_type_id, type: 'SplitTest' },
],
query: (query: Req['getSplitTest']) => ({
url: `split-testing/?${Utils.toParam(query)}`,
}),
transformResponse: (res: PagedResponse<ServersideSplitTestResult>) => {
const groupedFeatures = groupBy(
res.results,
(item) => item.feature.id,
)

const results: SplitTestResult[] = Object.keys(groupedFeatures).map(
(group) => {
const features = groupedFeatures[group]
let minP = Number.MAX_SAFE_INTEGER
let maxP = Number.MIN_SAFE_INTEGER
let maxConversionCount = Number.MIN_SAFE_INTEGER
let maxConversionPercentage = Number.MIN_SAFE_INTEGER
let minConversion = Number.MAX_SAFE_INTEGER
let maxConversionPValue = 0
const results = sortBy(
features.map((v) => {
if (v.pvalue < minP) {
minP = v.pvalue
}
if (v.pvalue > maxP) {
maxP = v.pvalue
}
const conversion = v.conversion_count
? Math.round(
(v.conversion_count / v.evaluation_count) * 100,
)
: 0
if (conversion > maxConversionPercentage) {
maxConversionCount = v.conversion_count
maxConversionPercentage = conversion
maxConversionPValue = v.pvalue
}
if (conversion < minConversion) {
minConversion = conversion
}

return {
confidence: Utils.convertToPConfidence(v.pvalue),
conversion_count: v.conversion_count,
conversion_percentage: conversion,
evaluation_count: v.evaluation_count,
pvalue: v.pvalue,
value_data: v.value_data,
} as SplitTestResult['results'][number]
}),
'conversion_count',
)
return {
conversion_variance: maxConversionPercentage - minConversion,
feature: features[0].feature,
max_conversion_count: maxConversionCount,
max_conversion_percentage: maxConversionPercentage,
max_conversion_pvalue: maxConversionPValue,
results: sortBy(results, (v) => -v.conversion_count),
}
},
)
return {
...res,
results,
}
},
}),
// END OF ENDPOINTS
}),
})

export async function getSplitTest(
store: any,
data: Req['getSplitTest'],
options?: Parameters<
typeof splitTestService.endpoints.getSplitTest.initiate
>[1],
) {
return store.dispatch(
splitTestService.endpoints.getSplitTest.initiate(data, options),
)
}
// END OF FUNCTION_EXPORTS

export const {
useGetSplitTestQuery,
// END OF EXPORTS
} = splitTestService

/* Usage examples:
const { data, isLoading } = useGetSplitTestQuery({ id: 2 }, {}) //get hook
const [createSplitTest, { isLoading, data, isSuccess }] = useCreateSplitTestMutation() //create hook
splitTestService.endpoints.getSplitTest.select({id: 2})(store.getState()) //access data from any function
*/
4 changes: 4 additions & 0 deletions frontend/common/types/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -597,5 +597,9 @@ export type Req = {
identity: string
projectId: string
}>
getConversionEvents: PagedRequest<{ q?: string; environment_id: string }>
getSplitTest: PagedRequest<{
conversion_event_type_id: string
}>
// END OF TYPES
}
53 changes: 53 additions & 0 deletions frontend/common/types/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,21 @@ export type SAMLAttributeMapping = {
django_attribute_name: AttributeName
idp_attribute_name: string
}
export type ServersideSplitTestResult = {
conversion_count: number
evaluation_count: number
feature: {
created_date: string
default_enabled: boolean
description: any
id: number
initial_value: string
name: string
type: string
}
pvalue: number
value_data: FeatureStateValue
}

export type HealthEventType = 'HEALTHY' | 'UNHEALTHY'

Expand All @@ -659,6 +674,42 @@ export type HealthProvider = {
webhook_url: number
}

export type PConfidence =
| 'VERY_LOW'
| 'LOW'
| 'REASONABLE'
| 'HIGH'
| 'VERY_HIGH'
export type SplitTestResult = {
results: {
conversion_count: number
evaluation_count: number
conversion_percentage: number
pvalue: number
confidence: PConfidence
value_data: FeatureStateValue
}[]
feature: {
created_date: string
default_enabled: boolean
description: any
id: number
initial_value: string
name: string
type: string
}
max_conversion_percentage: number
max_conversion_count: number
conversion_variance: number
max_conversion_pvalue: number
}

export type ConversionEvent = {
id: number
name: string
updated_at: string
created_at: string
}
export type Webhook = {
id: number
url: string
Expand Down Expand Up @@ -806,5 +857,7 @@ export type Res = {
organisationWebhooks: PagedResponse<Webhook>
identityTrait: { id: string }
identityTraits: IdentityTrait[]
conversionEvents: PagedResponse<ConversionEvent>
splitTest: PagedResponse<SplitTestResult>
// END OF TYPES
}
10 changes: 8 additions & 2 deletions frontend/common/utils/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ProjectFlag,
SegmentCondition,
Tag,
PConfidence,
} from 'common/types/responses'
import flagsmith from 'flagsmith'
import { ReactNode } from 'react'
Expand Down Expand Up @@ -57,7 +58,6 @@ const Utils = Object.assign({}, require('./base/_utils'), {
img.src = src
document.body.appendChild(img)
},

calculateControl(
multivariateOptions: MultivariateOption[],
variations?: MultivariateFeatureStateValue[],
Expand All @@ -79,7 +79,6 @@ const Utils = Object.assign({}, require('./base/_utils'), {
})
return 100 - total
},

calculateRemainingLimitsPercentage(
total: number | undefined,
max: number | undefined,
Expand Down Expand Up @@ -122,6 +121,13 @@ const Utils = Object.assign({}, require('./base/_utils'), {
return res
},

convertToPConfidence(value: number) {
if (value > 0.05) return 'LOW' as PConfidence
if (value >= 0.01) return 'REASONABLE' as PConfidence
if (value > 0.002) return 'HIGH' as PConfidence
return 'VERY_HIGH' as PConfidence
},

copyToClipboard: async (
value: string,
successMessage?: string,
Expand Down
25 changes: 25 additions & 0 deletions frontend/web/components/Confidence.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { FC } from 'react'
import cn from 'classnames'
import Utils from 'common/utils/utils'
import Format from 'common/utils/format'

type ConfidenceType = {
pValue: number
}

const Confidence: FC<ConfidenceType> = ({ pValue }) => {
const confidence = Utils.convertToPConfidence(pValue)
const confidenceDisplay = Format.enumeration.get(confidence)

const confidenceClass = cn({
'text-danger': confidence === 'VERY_LOW' || confidence === 'LOW',
'text-muted': !['VERY_LOW', 'LOW', 'HIGH', 'VERY_HIGH'].includes(
confidence,
),
'text-success': confidence === 'HIGH' || confidence === 'VERY_HIGH',
})

return <div className={confidenceClass}>{confidenceDisplay}</div>
}

export default Confidence
50 changes: 50 additions & 0 deletions frontend/web/components/ConversionEventSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React, { FC, useEffect, useState } from 'react'
import { useGetConversionEventsQuery } from 'common/services/useConversionEvent'
import useSearchThrottle from 'common/useSearchThrottle'
import { ConversionEvent } from 'common/types/responses'
import ProjectStore from 'common/stores/project-store'

type ConversionEventSelectType = {
onChange: (v: number) => void
environmentId: string
}

const ConversionEventSelect: FC<ConversionEventSelectType> = ({
environmentId,
onChange,
}) => {
const { search, searchInput, setSearchInput } = useSearchThrottle('')
const { data } = useGetConversionEventsQuery({
environment_id: ProjectStore.getEnvironmentIdFromKey(environmentId),
q: `${search}`,
})
const [selected, setSelected] = useState<ConversionEvent | null>(null)

return (
<div>
<Select
value={
selected
? { label: selected.name, value: selected.id }
: {
label: 'Select an Event',
value: '',
}
}
inputValue={searchInput}
onInputChange={setSearchInput}
options={data?.results?.map((result) => ({
...result,
label: result.name,
value: result.id,
}))}
onChange={(value: ConversionEvent) => {
setSelected(value)
onChange(value.id)
}}
/>
</div>
)
}

export default ConversionEventSelect
20 changes: 19 additions & 1 deletion frontend/web/components/pages/HomeAside.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ import ConfigProvider from 'common/providers/ConfigProvider'
import Permission from 'common/providers/Permission'
import { Link, NavLink } from 'react-router-dom'
import { IonIcon } from '@ionic/react'
import { checkmarkCircle, code, createOutline, warning } from 'ionicons/icons'
import {
checkmarkCircle,
code,
createOutline,
flask,
warning,
} from 'ionicons/icons'
import EnvironmentDropdown from 'components/EnvironmentDropdown'
import ProjectProvider from 'common/providers/ProjectProvider'
import OrganisationProvider from 'common/providers/OrganisationProvider'
Expand Down Expand Up @@ -340,6 +346,18 @@ const HomeAside: FC<HomeAsideType> = ({
/>
SDK Keys
</NavLink>
{Utils.getFlagsmithHasFeature(
'split_testing',
) && (
<NavLink
id='split-tests-link'
exact
to={`/project/${project.id}/environment/${environment.api_key}/split-tests`}
>
<IonIcon className='mr-2' icon={flask} />
Split Tests
</NavLink>
)}
{environmentAdmin && (
<NavLink
id='env-settings-link'
Expand Down
Loading
Loading