Skip to content

Commit

Permalink
feat: split testing UI (#5093)
Browse files Browse the repository at this point in the history
Co-authored-by: Matthew Elwell <[email protected]>
  • Loading branch information
kyle-ssg and matthewelwell authored Mar 3, 2025
1 parent 4348c3e commit 1a7155b
Show file tree
Hide file tree
Showing 10 changed files with 511 additions and 3 deletions.
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 @@ -636,6 +636,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 Down Expand Up @@ -671,6 +686,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 @@ -818,5 +869,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 @@ -341,6 +347,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

0 comments on commit 1a7155b

Please sign in to comment.