From a0942ac68a17f486d950f0ac39a224411d56656a Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Tue, 10 Sep 2024 16:50:58 +0100 Subject: [PATCH 01/12] Add split testing UI --- .../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 | 8 +- frontend/web/components/Confidence.tsx | 25 +++ .../web/components/ConversionEventSelect.tsx | 50 +++++ frontend/web/components/pages/HomeAside.tsx | 17 +- .../web/components/pages/SplitTestPage.tsx | 181 ++++++++++++++++++ frontend/web/routes.js | 8 + 10 files changed, 508 insertions(+), 2 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 e84f9b4a4300..6c0b0aa943a8 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -522,5 +522,9 @@ export type Req = { idp_attribute_name: 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 b26f76c33d97..e59ed4cbe4bf 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -594,7 +594,58 @@ 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 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 Res = { segments: PagedResponse segment: Segment @@ -714,5 +765,7 @@ export type Res = { metadata_xml: string } samlAttributeMapping: PagedResponse + conversionEvents: PagedResponse + splitTest: PagedResponse // END OF TYPES } diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index 6fbb9f5f9bd4..6261b3078e14 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -14,6 +14,7 @@ import { ProjectFlag, SegmentCondition, Tag, + PConfidence } from 'common/types/responses' import flagsmith from 'flagsmith' import { ReactNode } from 'react' @@ -57,7 +58,12 @@ const Utils = Object.assign({}, require('./base/_utils'), { img.src = src document.body.appendChild(img) }, - + 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 + }, calculateControl( multivariateOptions: MultivariateOption[], variations?: MultivariateFeatureStateValue[], 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 ( +
+