From 1a7155b29a6753ae097a2dc89950ab20c968fef5 Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Mon, 3 Mar 2025 12:20:51 +0000 Subject: [PATCH] feat: split testing UI (#5093) Co-authored-by: Matthew Elwell --- .../common/services/useConversionEvent.ts | 50 +++++ frontend/common/services/useSplitTest.ts | 114 +++++++++++ frontend/common/types/requests.ts | 4 + frontend/common/types/responses.ts | 53 +++++ frontend/common/utils/utils.tsx | 10 +- frontend/web/components/Confidence.tsx | 25 +++ .../web/components/ConversionEventSelect.tsx | 50 +++++ frontend/web/components/pages/HomeAside.tsx | 20 +- .../web/components/pages/SplitTestPage.tsx | 181 ++++++++++++++++++ frontend/web/routes.js | 7 + 10 files changed, 511 insertions(+), 3 deletions(-) create mode 100644 frontend/common/services/useConversionEvent.ts create mode 100644 frontend/common/services/useSplitTest.ts create mode 100644 frontend/web/components/Confidence.tsx create mode 100644 frontend/web/components/ConversionEventSelect.tsx create mode 100644 frontend/web/components/pages/SplitTestPage.tsx diff --git a/frontend/common/services/useConversionEvent.ts b/frontend/common/services/useConversionEvent.ts new file mode 100644 index 000000000000..4c6f0cc344fa --- /dev/null +++ b/frontend/common/services/useConversionEvent.ts @@ -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 +*/ diff --git a/frontend/common/services/useSplitTest.ts b/frontend/common/services/useSplitTest.ts new file mode 100644 index 000000000000..eff83c0c0a22 --- /dev/null +++ b/frontend/common/services/useSplitTest.ts @@ -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({ + providesTags: (res, _, q) => [ + { id: q?.conversion_event_type_id, type: 'SplitTest' }, + ], + query: (query: Req['getSplitTest']) => ({ + url: `split-testing/?${Utils.toParam(query)}`, + }), + transformResponse: (res: PagedResponse) => { + 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 +*/ diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 39290b1d5084..e235ccfe0009 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -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 } diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index abf23ece8e2c..2553edf8530d 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -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' @@ -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 @@ -818,5 +869,7 @@ export type Res = { organisationWebhooks: PagedResponse identityTrait: { id: string } identityTraits: IdentityTrait[] + conversionEvents: PagedResponse + splitTest: PagedResponse // END OF TYPES } diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index fd3591bd30a0..f3e58ee7b4eb 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -13,6 +13,7 @@ import { ProjectFlag, SegmentCondition, Tag, + PConfidence, } from 'common/types/responses' import flagsmith from 'flagsmith' import { ReactNode } from 'react' @@ -57,7 +58,6 @@ const Utils = Object.assign({}, require('./base/_utils'), { img.src = src document.body.appendChild(img) }, - calculateControl( multivariateOptions: MultivariateOption[], variations?: MultivariateFeatureStateValue[], @@ -79,7 +79,6 @@ const Utils = Object.assign({}, require('./base/_utils'), { }) return 100 - total }, - calculateRemainingLimitsPercentage( total: number | undefined, max: number | undefined, @@ -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, diff --git a/frontend/web/components/Confidence.tsx b/frontend/web/components/Confidence.tsx new file mode 100644 index 000000000000..97eb86a185a5 --- /dev/null +++ b/frontend/web/components/Confidence.tsx @@ -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 = ({ 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
{confidenceDisplay}
+} + +export default Confidence diff --git a/frontend/web/components/ConversionEventSelect.tsx b/frontend/web/components/ConversionEventSelect.tsx new file mode 100644 index 000000000000..64f7f608519d --- /dev/null +++ b/frontend/web/components/ConversionEventSelect.tsx @@ -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 = ({ + environmentId, + onChange, +}) => { + const { search, searchInput, setSearchInput } = useSearchThrottle('') + const { data } = useGetConversionEventsQuery({ + environment_id: ProjectStore.getEnvironmentIdFromKey(environmentId), + q: `${search}`, + }) + const [selected, setSelected] = useState(null) + + return ( +
+