diff --git a/frontend/src/component/feature/FeatureView/FeatureExperiment/FeatureExperimentOverview.tsx b/frontend/src/component/feature/FeatureView/FeatureExperiment/FeatureExperimentOverview.tsx new file mode 100644 index 0000000000..ec3fe4acca --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureExperiment/FeatureExperimentOverview.tsx @@ -0,0 +1,1777 @@ +import { useEffect, useMemo, useState, type CSSProperties } from 'react'; +import { + IN, + NOT_IN, + STR_CONTAINS, + STR_ENDS_WITH, + STR_STARTS_WITH, + type Operator, +} from 'constants/operators'; +import { + Alert, + Box, + Button, + Chip, + Divider, + IconButton, + InputAdornment, + Link as MuiLink, + ListSubheader, + MenuItem, + Paper, + Stack, + TextField, + Tooltip, + Typography, + styled, + useTheme, +} from '@mui/material'; +import Add from '@mui/icons-material/Add'; +import Delete from '@mui/icons-material/Delete'; +import HelpOutlineOutlined from '@mui/icons-material/HelpOutlineOutlined'; +import CheckCircleOutline from '@mui/icons-material/CheckCircleOutlineOutlined'; +import VisibilityOutlined from '@mui/icons-material/VisibilityOutlined'; +import PermissionButton from 'component/common/PermissionButton/PermissionButton'; +import { + CREATE_FEATURE_STRATEGY, + UPDATE_FEATURE, + UPDATE_FEATURE_ENVIRONMENT, + UPDATE_FEATURE_STRATEGY, +} from 'component/providers/AccessProvider/permissions'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import useFeatureStrategyApi from 'hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi'; +import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; +import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi'; +import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; +import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests'; +import { useAssignableUnleashContext } from 'hooks/api/getters/useUnleashContext/useAssignableUnleashContext'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import type { + IFeatureEnvironment, + IFeatureToggle, +} from 'interfaces/featureToggle'; +import { + createExperimentStrategyPayload, + createTreatmentKey, + experimentIsValid, + getExperimentContextFields, + getExperimentPropertyNames, + laneTotal, + resolveExperimentConfig, + resolvePreviewTreatment, + type ExperimentEnvironmentConfig, + type ExperimentLane, + type ExperimentTreatment, + type TreatmentConstraint, +} from './experimentStrategy.ts'; + +const Page = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), +})); + +const Builder = styled(Paper)(({ theme }) => ({ + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadiusLarge, + boxShadow: 'none', + overflow: 'hidden', +})); + +const BuilderHeader = styled('div')(({ theme }) => ({ + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr) auto', + gap: theme.spacing(2), + alignItems: 'center', + padding: theme.spacing(3), + borderBottom: `1px solid ${theme.palette.divider}`, + [theme.breakpoints.down('md')]: { + gridTemplateColumns: '1fr', + }, +})); + +const BuilderBody = styled('div')(({ theme }) => ({ + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr) 320px', + gap: theme.spacing(3), + padding: theme.spacing(3), + [theme.breakpoints.down('md')]: { + gridTemplateColumns: '1fr', + }, +})); + +const EnvironmentPicker = styled('div')(({ theme }) => ({ + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(1), +})); + +const VariantGrid = styled('div')(({ theme }) => ({ + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', + gap: theme.spacing(2), +})); + +const VariantCard = styled(Paper, { + shouldForwardProp: (prop) => prop !== 'decorationColor', +})<{ decorationColor: string }>(({ theme, decorationColor }) => ({ + border: `1px solid ${theme.palette.divider}`, + borderLeft: `4px solid ${decorationColor}`, + borderRadius: theme.shape.borderRadius, + boxShadow: 'none', + padding: theme.spacing(2), +})); + +const LaneCard = styled(Paper)(({ theme }) => ({ + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + boxShadow: 'none', + padding: theme.spacing(1.5), +})); + +const LaneBody = styled('div')(({ theme }) => ({ + display: 'grid', + gridTemplateColumns: 'minmax(300px, 1.15fr) minmax(260px, 0.85fr)', + gap: theme.spacing(2), + alignItems: 'start', + [theme.breakpoints.down('lg')]: { + gridTemplateColumns: '1fr', + }, +})); + +const ConstraintRow = styled('div')(({ theme }) => ({ + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr) minmax(140px, 180px) 32px', + gap: theme.spacing(1), + alignItems: 'center', + padding: theme.spacing(1), + borderRadius: theme.shape.borderRadius, + background: theme.palette.background.elevation1, + '& > *': { minWidth: 0 }, + '& .constraint-values': { gridColumn: '1 / 3' }, + '& .constraint-delete': { + gridColumn: '3', + gridRow: '1 / 3', + alignSelf: 'center', + }, + [theme.breakpoints.down('md')]: { + gridTemplateColumns: '1fr', + '& .constraint-values, & .constraint-delete': { + gridColumn: 'auto', + gridRow: 'auto', + }, + }, +})); + +const AllocationGrid = styled('div')(({ theme }) => ({ + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', + gap: theme.spacing(1), +})); + +const PropertyGrid = styled('div')(({ theme }) => ({ + display: 'grid', + gridTemplateColumns: + '180px repeat(var(--treatment-count), minmax(160px, 1fr)) 40px', + gap: theme.spacing(1), + alignItems: 'center', + overflowX: 'auto', + paddingBottom: theme.spacing(1), +})); + +const TreatmentHeader = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), +})); + +const ColorSwatch = styled('span')<{ color: string }>(({ color }) => ({ + width: 10, + height: 10, + borderRadius: '50%', + background: color, + flex: '0 0 auto', +})); + +const PreviewCode = styled('pre')(({ theme }) => ({ + margin: 0, + padding: theme.spacing(1.5), + borderRadius: theme.shape.borderRadius, + background: theme.palette.background.elevation1, + border: `1px solid ${theme.palette.divider}`, + overflowX: 'auto', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + fontSize: theme.typography.body2.fontSize, +})); + +const percentInputProps = { + endAdornment: %, +}; + +const treatmentConstraintOperators: Operator[] = [ + IN, + NOT_IN, + STR_CONTAINS, + STR_STARTS_WITH, + STR_ENDS_WITH, +]; + +const operatorLabels: Partial> = { + [IN]: 'is one of', + [NOT_IN]: 'is not one of', + [STR_CONTAINS]: 'contains', + [STR_STARTS_WITH]: 'starts with', + [STR_ENDS_WITH]: 'ends with', +}; + +const defaultPropertyNames = ['headline', 'buttonColor']; + +const statusText = ( + config: ExperimentEnvironmentConfig, + environmentEnabled = config.environment.enabled, +): string => { + if (!config.configured) return 'Not configured'; + if (!environmentEnabled) return 'Environment off'; + return 'Running'; +}; + +const createUniqueTreatmentKey = ( + label: string, + treatments: ExperimentTreatment[], +): string => { + const baseKey = createTreatmentKey(label); + const existing = new Set(treatments.map((treatment) => treatment.name)); + if (!existing.has(baseKey)) return baseKey; + + let index = 2; + while (existing.has(`${baseKey}-${index}`)) index += 1; + return `${baseKey}-${index}`; +}; + +const propertyNamesAreValid = (propertyNames: string[]): boolean => { + const normalized = propertyNames.map((name) => name.trim()).filter(Boolean); + return ( + normalized.length === propertyNames.length && + new Set(normalized).size === normalized.length + ); +}; + +const roundOneDecimal = (value: number): number => Math.round(value * 10) / 10; + +const treatmentColor = (colors: string[], index: number): string => + colors[index % colors.length] ?? '#6C65E5'; + +const parseConstraintValues = (rawValue: string): string[] => + rawValue + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + +const formatPreviewPayload = (treatment?: ExperimentTreatment): string => + JSON.stringify(treatment?.properties ?? {}, null, 2); + +const previewContextFields = ( + fields: string[], + lanes: ExperimentLane[], +): string[] => { + const stickinessFields = lanes + .map((lane) => lane.stickiness) + .filter((field) => field && field !== 'default' && field !== 'random'); + + return Array.from(new Set([...fields, ...stickinessFields])); +}; + +type ContextFieldGroup = { groupHeader: string; options: string[] }; + +const createContextFieldGroups = ( + context: { name: string; project?: string }[], + lanes: ExperimentLane[], +): ContextFieldGroup[] => { + const selectedContextNames = new Set( + lanes.flatMap((lane) => + lane.constraints.map((constraint) => constraint.contextName), + ), + ); + const existingContextNames = new Set(context.map((field) => field.name)); + const deletedContextNames = Array.from(selectedContextNames).filter( + (name) => name && !existingContextNames.has(name), + ); + const projectFields = context + .filter((field) => Boolean(field.project)) + .map((field) => field.name) + .toSorted(); + const globalFields = context + .filter((field) => !field.project) + .map((field) => field.name) + .toSorted(); + + return [ + projectFields.length > 0 && { + groupHeader: 'Project context fields', + options: projectFields, + }, + globalFields.length > 0 && { + groupHeader: 'Global context fields', + options: globalFields, + }, + deletedContextNames.length > 0 && { + groupHeader: 'Deleted context fields', + options: deletedContextNames.toSorted(), + }, + ].filter(Boolean) as ContextFieldGroup[]; +}; + +const recalculateLaneControlWeight = ( + lane: ExperimentLane, + treatments: ExperimentTreatment[], +): ExperimentLane => { + const controlName = treatments[0]?.name; + if (!controlName) return lane; + + const assigned = treatments + .slice(1) + .reduce( + (sum, treatment) => sum + (lane.weights[treatment.name] ?? 0), + 0, + ); + + return { + ...lane, + weights: { + ...lane.weights, + [controlName]: roundOneDecimal(100 - assigned), + }, + }; +}; + +const normalizeLaneWeights = ( + lane: ExperimentLane, + treatments: ExperimentTreatment[], +): ExperimentLane => ({ + ...lane, + weights: Object.fromEntries( + treatments.map((treatment) => [ + treatment.name, + lane.weights[treatment.name] ?? 0, + ]), + ), +}); + +type ExperimentEnvironmentEditorProps = { + feature: IFeatureToggle; + environment: IFeatureEnvironment; + onSaved: () => void; +}; + +const ExperimentEnvironmentEditor = ({ + feature, + environment, + onSaved, +}: ExperimentEnvironmentEditorProps) => { + const theme = useTheme(); + const colors = theme.palette.variants; + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + const config = useMemo( + () => resolveExperimentConfig(environment), + [environment], + ); + const [treatments, setTreatments] = useState(config.treatments); + const [lanes, setLanes] = useState(() => + config.lanes.map((lane) => + normalizeLaneWeights(lane, config.treatments), + ), + ); + const [removedLaneIds, setRemovedLaneIds] = useState([]); + const [propertyNames, setPropertyNames] = useState(() => { + const names = getExperimentPropertyNames(config.treatments); + return names.length > 0 ? names : defaultPropertyNames; + }); + const [environmentEnabled, setEnvironmentEnabled] = useState( + environment.enabled, + ); + const [previewContext, setPreviewContext] = useState< + Record + >({}); + const [constraintValueDrafts, setConstraintValueDrafts] = useState< + Record + >({}); + + useEffect(() => { + setEnvironmentEnabled(environment.enabled); + }, [environment.enabled]); + + useEffect(() => { + setTreatments(config.treatments); + setLanes( + config.lanes.map((lane) => + normalizeLaneWeights(lane, config.treatments), + ), + ); + setRemovedLaneIds([]); + const names = getExperimentPropertyNames(config.treatments); + setPropertyNames(names.length > 0 ? names : defaultPropertyNames); + setConstraintValueDrafts({}); + }, [config]); + + const { + addStrategyToFeature, + updateStrategyOnFeature, + deleteStrategyFromFeature, + setStrategiesSortOrder, + loading, + } = useFeatureStrategyApi(); + const { + patchFeatureToggle, + toggleFeatureEnvironmentOn, + toggleFeatureEnvironmentOff, + loading: featureApiLoading, + } = useFeatureApi(); + const { addChange } = useChangeRequestApi(); + const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); + const { refetch: refetchChangeRequests } = + usePendingChangeRequests(projectId); + const { setToastData, setToastApiError } = useToast(); + const { context: assignableContext } = + useAssignableUnleashContext(projectId); + + const contextFieldGroups = useMemo( + () => createContextFieldGroups(assignableContext, lanes), + [assignableContext, lanes], + ); + const contextFieldOptions = contextFieldGroups.flatMap( + (group) => group.options, + ); + const defaultContextField = contextFieldOptions[0] ?? 'userId'; + const contextFields = previewContextFields( + getExperimentContextFields(lanes), + lanes, + ); + const preview = resolvePreviewTreatment(treatments, lanes, previewContext, { + groupId: featureId, + }); + const previewTreatmentIndex = Math.max( + treatments.findIndex( + (treatment) => treatment.name === preview.treatment?.name, + ), + 0, + ); + const valid = + experimentIsValid(treatments, lanes) && + propertyNamesAreValid(propertyNames); + const permission = config.configured + ? UPDATE_FEATURE_STRATEGY + : CREATE_FEATURE_STRATEGY; + + const syncTreatmentRename = (index: number, nextName: string) => { + const previousName = treatments[index].name; + const name = createTreatmentKey(nextName); + setTreatments((current) => + current.map((treatment, currentIndex) => + currentIndex === index + ? { ...treatment, name, label: name } + : treatment, + ), + ); + setLanes((current) => + current.map((lane) => { + const { [previousName]: previousWeight, ...rest } = + lane.weights; + return { + ...lane, + weights: { ...rest, [name]: previousWeight ?? 0 }, + }; + }), + ); + }; + + const addTreatment = () => { + const nextLabel = `Variant${String.fromCharCode(65 + treatments.length)}`; + const name = createUniqueTreatmentKey(nextLabel, treatments); + setTreatments((current) => [ + ...current, + { + name, + label: name, + properties: Object.fromEntries( + propertyNames.map((propertyName) => [propertyName, '']), + ), + }, + ]); + setLanes((current) => + current.map((lane) => + recalculateLaneControlWeight( + { ...lane, weights: { ...lane.weights, [name]: 0 } }, + [...treatments, { name, label: name, properties: {} }], + ), + ), + ); + }; + + const removeTreatment = (index: number) => { + if (treatments.length <= 2) return; + const removedName = treatments[index].name; + const nextTreatments = treatments.filter( + (_, currentIndex) => currentIndex !== index, + ); + setTreatments(nextTreatments); + setLanes((current) => + current.map((lane) => { + const { [removedName]: _removed, ...weights } = lane.weights; + return recalculateLaneControlWeight( + { ...lane, weights }, + nextTreatments, + ); + }), + ); + }; + + const updateLane = (laneIndex: number, patch: Partial) => { + setLanes((current) => + current.map((lane, index) => + index === laneIndex ? { ...lane, ...patch } : lane, + ), + ); + }; + + const updateLaneWeight = ( + laneIndex: number, + treatmentIndex: number, + rawValue: string, + ) => { + if (treatmentIndex === 0) return; + const parsed = Number(rawValue); + if (Number.isNaN(parsed)) return; + + setLanes((current) => + current.map((lane, index) => { + if (index !== laneIndex) return lane; + const otherAssigned = treatments + .slice(1) + .reduce( + (sum, treatment, currentTreatmentIndex) => + currentTreatmentIndex + 1 === treatmentIndex + ? sum + : sum + (lane.weights[treatment.name] ?? 0), + 0, + ); + const max = Math.max(100 - otherAssigned, 0); + const weight = roundOneDecimal( + Math.min(Math.max(parsed, 0), max), + ); + return recalculateLaneControlWeight( + { + ...lane, + weights: { + ...lane.weights, + [treatments[treatmentIndex].name]: weight, + }, + }, + treatments, + ); + }), + ); + }; + + const addLane = () => { + setLanes((current) => { + const fallbackLane = current.at(-1); + const newLane = { + name: `Lane ${current.length + 1}`, + constraints: [ + { + contextName: defaultContextField, + operator: IN, + values: [], + inverted: false, + }, + ], + segments: [], + disabled: false, + stickiness: fallbackLane?.stickiness ?? 'default', + weights: fallbackLane?.weights + ? { ...fallbackLane.weights } + : {}, + }; + + return current.length === 0 ? [newLane] : [newLane, ...current]; + }); + }; + + const removeLane = (laneIndex: number) => { + if (lanes.length <= 1 || laneIndex === lanes.length - 1) return; + const lane = lanes[laneIndex]; + if (lane.id) setRemovedLaneIds((current) => [...current, lane.id!]); + setLanes((current) => + current.filter((_, index) => index !== laneIndex), + ); + }; + + const updateLaneConstraint = ( + laneIndex: number, + constraintIndex: number, + patch: Partial, + ) => { + setLanes((current) => + current.map((lane, index) => + index === laneIndex + ? { + ...lane, + constraints: lane.constraints.map( + (constraint, currentConstraintIndex) => + currentConstraintIndex === constraintIndex + ? { ...constraint, ...patch } + : constraint, + ), + } + : lane, + ), + ); + }; + + const addLaneConstraint = (laneIndex: number) => { + if (laneIndex === lanes.length - 1) return; + + setLanes((current) => + current.map((lane, index) => + index === laneIndex + ? { + ...lane, + constraints: [ + ...lane.constraints, + { + contextName: defaultContextField, + operator: IN, + values: [], + inverted: false, + }, + ], + } + : lane, + ), + ); + }; + + const removeLaneConstraint = ( + laneIndex: number, + constraintIndex: number, + ) => { + setLanes((current) => + current.map((lane, index) => + index === laneIndex + ? { + ...lane, + constraints: lane.constraints.filter( + (_, currentConstraintIndex) => + currentConstraintIndex !== constraintIndex, + ), + } + : lane, + ), + ); + }; + + const constraintDraftKey = ( + laneIndex: number, + constraintIndex: number, + ): string => `${laneIndex}:${constraintIndex}`; + + const addConstraintValues = ( + laneIndex: number, + constraintIndex: number, + rawValue: string, + ) => { + const nextValues = parseConstraintValues(rawValue); + if (nextValues.length === 0) return; + const constraint = lanes[laneIndex]?.constraints[constraintIndex]; + const values = Array.from( + new Set([...(constraint?.values ?? []), ...nextValues]), + ); + updateLaneConstraint(laneIndex, constraintIndex, { values }); + setConstraintValueDrafts((current) => ({ + ...current, + [constraintDraftKey(laneIndex, constraintIndex)]: '', + })); + }; + + const removeConstraintValue = ( + laneIndex: number, + constraintIndex: number, + value: string, + ) => { + const constraint = lanes[laneIndex]?.constraints[constraintIndex]; + updateLaneConstraint(laneIndex, constraintIndex, { + values: (constraint?.values ?? []).filter( + (currentValue) => currentValue !== value, + ), + }); + }; + + const updateTreatmentProperty = ( + treatmentIndex: number, + propertyName: string, + value: string, + ) => { + setTreatments((current) => + current.map((treatment, index) => + index === treatmentIndex + ? { + ...treatment, + properties: { + ...treatment.properties, + [propertyName]: value, + }, + } + : treatment, + ), + ); + }; + + const renameProperty = (index: number, nextName: string) => { + const previousName = propertyNames[index]; + setPropertyNames((current) => + current.map((name, i) => (i === index ? nextName : name)), + ); + setTreatments((current) => + current.map((treatment) => { + const { [previousName]: value, ...rest } = treatment.properties; + return { + ...treatment, + properties: nextName + ? { ...rest, [nextName]: value ?? '' } + : rest, + }; + }), + ); + }; + + const removeProperty = (propertyName: string) => { + setPropertyNames((current) => + current.filter((name) => name !== propertyName), + ); + setTreatments((current) => + current.map((treatment) => { + const { [propertyName]: _removed, ...properties } = + treatment.properties; + return { ...treatment, properties }; + }), + ); + }; + + const addProperty = () => { + let index = propertyNames.length + 1; + let name = `property${index}`; + while (propertyNames.includes(name)) { + index += 1; + name = `property${index}`; + } + setPropertyNames((current) => [...current, name]); + setTreatments((current) => + current.map((treatment) => ({ + ...treatment, + properties: { ...treatment.properties, [name]: '' }, + })), + ); + }; + + const resetExperiment = () => { + setTreatments(config.treatments); + setLanes( + config.lanes.map((lane) => + normalizeLaneWeights(lane, config.treatments), + ), + ); + setRemovedLaneIds([]); + const names = getExperimentPropertyNames(config.treatments); + setPropertyNames(names.length > 0 ? names : defaultPropertyNames); + setPreviewContext({}); + setConstraintValueDrafts({}); + }; + + const toggleEnvironment = async () => { + try { + if (environmentEnabled) { + await toggleFeatureEnvironmentOff( + projectId, + featureId, + environment.name, + ); + setEnvironmentEnabled(false); + setToastData({ type: 'success', text: 'Environment disabled' }); + } else { + await toggleFeatureEnvironmentOn( + projectId, + featureId, + environment.name, + true, + ); + setEnvironmentEnabled(true); + setToastData({ type: 'success', text: 'Environment enabled' }); + } + onSaved(); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const enableImpressionData = async () => { + try { + await patchFeatureToggle(projectId, featureId, [ + { op: 'replace', path: '/impressionData', value: true }, + ]); + setToastData({ type: 'success', text: 'Impression data enabled' }); + onSaved(); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const onSave = async () => { + const lanesForSave = lanes.map((lane, index) => + index === lanes.length - 1 + ? { ...lane, name: 'Fallback', constraints: [] } + : lane, + ); + const changes = lanesForSave.map((lane, sortOrder) => { + const payload = createExperimentStrategyPayload({ + featureId, + lane, + treatments, + sortOrder, + }); + return { + lane, + payload, + changeRequestPayload: lane.id + ? { ...payload, id: lane.id } + : payload, + action: lane.id + ? ('updateStrategy' as const) + : ('addStrategy' as const), + }; + }); + + try { + if (isChangeRequestConfigured(environment.name)) { + await addChange(projectId, environment.name, [ + ...changes.map(({ action, changeRequestPayload }) => ({ + action, + feature: featureId, + payload: changeRequestPayload, + })), + ...removedLaneIds.map((id) => ({ + action: 'deleteStrategy' as const, + feature: featureId, + payload: { id }, + })), + { + action: 'reorderStrategy' as const, + feature: featureId, + payload: lanesForSave + .filter((lane) => Boolean(lane.id)) + .map((lane, sortOrder) => ({ + id: lane.id!, + sortOrder, + })), + }, + ]); + refetchChangeRequests(); + setToastData({ + type: 'success', + text: 'Experiment changes added to draft', + }); + } else { + const savedLanes = await Promise.all( + changes.map(async ({ lane, payload }) => { + if (lane.id) { + await updateStrategyOnFeature( + projectId, + featureId, + environment.name, + lane.id, + payload, + ); + return lane; + } + + const strategy = await addStrategyToFeature( + projectId, + featureId, + environment.name, + payload, + ); + + return { ...lane, id: strategy.id }; + }), + ); + await Promise.all( + removedLaneIds.map((id) => + deleteStrategyFromFeature( + projectId, + featureId, + environment.name, + id, + ), + ), + ); + await setStrategiesSortOrder( + projectId, + featureId, + environment.name, + savedLanes.map((lane, sortOrder) => ({ + id: lane.id!, + sortOrder, + })), + ); + setLanes(savedLanes); + setRemovedLaneIds([]); + setToastData({ type: 'success', text: 'Experiment updated' }); + } + onSaved(); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const renderConstraint = ( + lane: ExperimentLane, + laneIndex: number, + constraint: TreatmentConstraint, + constraintIndex: number, + ) => ( + + + updateLaneConstraint(laneIndex, constraintIndex, { + contextName: event.target.value, + }) + } + size='small' + > + {contextFieldGroups.map((group) => [ + + {group.groupHeader} + , + ...group.options.map((option) => ( + + {option} + + )), + ])} + + + updateLaneConstraint(laneIndex, constraintIndex, { + operator: event.target.value as Operator, + }) + } + size='small' + > + {treatmentConstraintOperators.map((operator) => ( + + {operatorLabels[operator]} + + ))} + + + {(constraint.values?.length ?? 0) > 0 ? ( + + {constraint.values?.map((value) => ( + + removeConstraintValue( + laneIndex, + constraintIndex, + value, + ) + } + size='small' + /> + ))} + + ) : null} + + + setConstraintValueDrafts((current) => ({ + ...current, + [constraintDraftKey( + laneIndex, + constraintIndex, + )]: event.target.value, + })) + } + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ',') + return; + event.preventDefault(); + addConstraintValues( + laneIndex, + constraintIndex, + constraintValueDrafts[ + constraintDraftKey( + laneIndex, + constraintIndex, + ) + ] ?? '', + ); + }} + size='small' + /> + + + + removeLaneConstraint(laneIndex, constraintIndex)} + size='small' + > + + + + ); + + return ( + + + + {environment.name} + + + + + + + + {environmentEnabled ? 'Stop serving' : 'Start serving'} + + + + + {!feature.impressionData ? ( + + Enable + + } + > + Enable impression data to emit `getVariant` events + for experiment analysis. + + ) : null} + + {config.hasAdvancedConfiguration ? ( + + This environment has non-experiment strategies. This + view edits flexible rollout experiment audiences + only. + + ) : null} + + + + + Treatments + + + Variant keys are global. Each audience + can allocate traffic differently. + + + + + + + + + + {treatments.map((treatment, index) => ( + + + + + + + Treatment {index + 1} + + + + removeTreatment(index) + } + size='small' + > + + + + + syncTreatmentRename( + index, + event.target.value, + ) + } + /> + + + ))} + + + + + + + + + + Treatment properties + + + Properties are global per treatment and + stored as JSON variant payloads. + + + + + + + Property + + {treatments.map((treatment, index) => ( + + + + {treatment.name} + + + ))} + + {propertyNames.map( + (propertyName, propertyIndex) => ( + + + renameProperty( + propertyIndex, + createTreatmentKey( + event.target.value, + ), + ) + } + /> + {treatments.map( + (treatment, treatmentIndex) => ( + + updateTreatmentProperty( + treatmentIndex, + propertyName, + event.target.value, + ) + } + /> + ), + )} + + removeProperty(propertyName) + } + > + + + + ), + )} + + {!propertyNamesAreValid(propertyNames) ? ( + + Property keys must be unique and URL friendly. + + ) : null} + + + + + + + + Audiences + + Each audience is a gradual rollout strategy + with its own targeting and allocation. + + + + + {lanes.map((lane, laneIndex) => { + const isFallbackLane = + laneIndex === lanes.length - 1; + + return ( + + + + + {isFallbackLane ? ( + + + Fallback + + + + ) : ( + + updateLane( + laneIndex, + { + name: event + .target + .value, + }, + ) + } + size='small' + fullWidth + /> + )} + + + updateLane(laneIndex, { + stickiness: + event.target.value, + }) + } + size='small' + > + {Array.from( + new Set([ + 'default', + 'userId', + 'sessionId', + 'random', + lane.stickiness, + ]), + ).map((option) => ( + + {option} + + ))} + + + removeLane(laneIndex) + } + size='small' + > + + + + + + + + + Constraints + + + + + + + + {isFallbackLane ? ( + + Fallback constraints are + disabled to avoid + unassigned contexts. + + ) : lane.constraints.length === + 0 ? ( + + Add targeting rules to + make this audience + specific. + + ) : null} + {!isFallbackLane + ? lane.constraints.map( + ( + constraint, + constraintIndex, + ) => + renderConstraint( + lane, + laneIndex, + constraint, + constraintIndex, + ), + ) + : null} + + + + Allocation + + + {treatments.map( + ( + treatment, + treatmentIndex, + ) => ( + + updateLaneWeight( + laneIndex, + treatmentIndex, + event + .target + .value, + ) + } + size='small' + /> + ), + )} + + {laneTotal(lane) !== 100 ? ( + + Current allocation + total: {laneTotal(lane)} + %. + + ) : null} + + + + + ); + })} + {!experimentIsValid(treatments, lanes) ? ( + + Treatments need unique URL-friendly variant keys + and every audience allocation must total 100%. + + ) : null} + + + + + + + + + Evaluation preview + + + + {contextFields.length === 0 ? ( + + Add constraints or stickiness fields to + preview targeting. + + ) : null} + {contextFields.map((field) => ( + + setPreviewContext((current) => ({ + ...current, + [field]: event.target.value, + })) + } + /> + ))} + + + + + + Serves{' '} + + {preview.treatment?.name ?? + 'no treatment'} + + + {preview.treatment ? ( + + + + {preview.lane?.name ?? + 'No audience'} + + + ) : null} + + + + {formatPreviewPayload(preview.treatment)} + + + + + Save experiment + + + + + + ); +}; + +type FeatureExperimentOverviewProps = { + feature: IFeatureToggle; + onChange: () => void; +}; + +export const FeatureExperimentOverview = ({ + feature, + onChange, +}: FeatureExperimentOverviewProps) => { + const [selectedEnvironment, setSelectedEnvironment] = useState( + feature.environments[0]?.name, + ); + + useEffect(() => { + if ( + selectedEnvironment && + feature.environments.some( + (environment) => environment.name === selectedEnvironment, + ) + ) + return; + setSelectedEnvironment(feature.environments[0]?.name); + }, [feature.environments, selectedEnvironment]); + + const environment = feature.environments.find( + (environment) => environment.name === selectedEnvironment, + ); + + return ( + + + Experiment flags use multiple gradual rollout strategies as + audiences. Constraints live on each strategy; treatment + properties stay on variant payloads. + + + {feature.environments.map((environment) => { + const config = resolveExperimentConfig(environment); + return ( + + ); + })} + + {environment ? ( + + ) : null} + + Need raw strategy controls? Open{' '} + + the A/B testing guide + + . + + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureExperiment/experimentStrategy.test.ts b/frontend/src/component/feature/FeatureView/FeatureExperiment/experimentStrategy.test.ts new file mode 100644 index 0000000000..6234a2715e --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureExperiment/experimentStrategy.test.ts @@ -0,0 +1,350 @@ +import { describe, expect, test } from 'vitest'; +import { IN } from 'constants/operators'; +import type { IFeatureEnvironment } from 'interfaces/featureToggle'; +import { + type ExperimentTreatment, + createExperimentStrategyPayload, + createTreatmentKey, + experimentIsValid, + getExperimentPropertyNames, + resolveExperimentConfig, + resolvePreviewTreatment, +} from './experimentStrategy.ts'; + +const environment = ( + strategies: IFeatureEnvironment['strategies'], +): IFeatureEnvironment => ({ + name: 'development', + type: 'development', + enabled: true, + strategies, +}); + +describe('experimentStrategy', () => { + test('maps an empty environment to a default experiment', () => { + const config = resolveExperimentConfig(environment([])); + + expect(config.configured).toBe(false); + expect(config.treatments).toEqual([ + { name: 'Control', label: 'Control', properties: {} }, + { name: 'VariantB', label: 'VariantB', properties: {} }, + ]); + expect(config.lanes).toMatchObject([ + { + name: 'All traffic', + constraints: [], + weights: { Control: 50, VariantB: 50 }, + }, + ]); + }); + + test('maps flexible rollout strategies to swimlanes with shared treatments', () => { + const config = resolveExperimentConfig( + environment([ + { + id: 'lane-1', + name: 'flexibleRollout', + title: 'Hosted apps', + sortOrder: 1, + constraints: [ + { + contextName: 'appName', + operator: IN, + values: ['hosted'], + }, + ], + segments: [], + disabled: false, + parameters: { + rollout: '100', + stickiness: 'userId', + groupId: 'abtest', + }, + variants: [ + { + name: 'Control', + stickiness: 'userId', + weight: 334, + weightType: 'variable', + payload: { + type: 'json', + value: JSON.stringify({ + headline: 'control-headline', + buttonColor: 'blue', + }), + }, + }, + { + name: 'VariantB', + stickiness: 'userId', + weight: 333, + weightType: 'variable', + payload: { + type: 'json', + value: JSON.stringify({ + headline: 'b-headline', + buttonColor: 'orange', + }), + }, + }, + { + name: 'VariantC', + stickiness: 'userId', + weight: 333, + weightType: 'variable', + payload: { + type: 'json', + value: JSON.stringify({ + headline: 'c-headline', + buttonColor: 'green', + }), + }, + }, + ], + }, + { + id: 'lane-2', + name: 'flexibleRollout', + sortOrder: 0, + constraints: [], + segments: [], + disabled: false, + parameters: { + rollout: '100', + stickiness: 'userId', + groupId: 'abtest', + }, + variants: [ + { + name: 'Control', + stickiness: 'userId', + weight: 250, + weightType: 'variable', + }, + { + name: 'VariantB', + stickiness: 'userId', + weight: 500, + weightType: 'variable', + }, + { + name: 'VariantC', + stickiness: 'userId', + weight: 250, + weightType: 'variable', + }, + ], + }, + ]), + ); + + expect(config.configured).toBe(true); + expect(config.treatments).toEqual([ + { + name: 'Control', + label: 'Control', + properties: { + headline: 'control-headline', + buttonColor: 'blue', + }, + }, + { + name: 'VariantB', + label: 'VariantB', + properties: { headline: 'b-headline', buttonColor: 'orange' }, + }, + { + name: 'VariantC', + label: 'VariantC', + properties: { headline: 'c-headline', buttonColor: 'green' }, + }, + ]); + expect(config.lanes).toMatchObject([ + { + id: 'lane-1', + name: 'Hosted apps', + constraints: [ + { + contextName: 'appName', + operator: IN, + values: ['hosted'], + }, + ], + weights: { Control: 33.4, VariantB: 33.3, VariantC: 33.3 }, + }, + { + id: 'lane-2', + constraints: [], + weights: { Control: 25, VariantB: 50, VariantC: 25 }, + }, + ]); + }); + + test('creates one strategy payload per swimlane', () => { + const treatments = [ + { + name: 'Control', + label: 'Control', + properties: { + headline: 'control-headline', + buttonColor: 'blue', + }, + }, + { + name: 'VariantB', + label: 'VariantB', + properties: { headline: 'b-headline', buttonColor: 'orange' }, + }, + ]; + const payload = createExperimentStrategyPayload({ + featureId: 'abtest', + treatments, + sortOrder: 0, + lane: { + id: 'lane-1', + name: 'Hosted apps', + constraints: [ + { + contextName: 'appName', + operator: IN, + values: ['hosted'], + }, + ], + segments: [], + disabled: false, + stickiness: 'userId', + weights: { Control: 25, VariantB: 75 }, + }, + }); + + expect(payload).toMatchObject({ + name: 'flexibleRollout', + title: 'Hosted apps', + sortOrder: 0, + constraints: [ + { contextName: 'appName', operator: IN, values: ['hosted'] }, + ], + parameters: { + rollout: '100', + stickiness: 'userId', + groupId: 'abtest', + }, + variants: [ + { + name: 'Control', + weight: 250, + stickiness: 'userId', + weightType: 'variable', + }, + { + name: 'VariantB', + weight: 750, + stickiness: 'userId', + weightType: 'fix', + }, + ], + }); + expect(payload.variants?.[0].payload).toEqual({ + type: 'json', + value: JSON.stringify({ + headline: 'control-headline', + buttonColor: 'blue', + }), + }); + }); + + test('previews first matching swimlane and weighted treatment assignment', () => { + const treatments = [ + { name: 'Control', label: 'Control', properties: {} }, + { name: 'VariantB', label: 'VariantB', properties: {} }, + { name: 'VariantC', label: 'VariantC', properties: {} }, + ]; + const lanes = [ + { + name: 'Hosted apps', + constraints: [ + { + contextName: 'appName', + operator: IN, + values: ['hosted'], + }, + ], + segments: [], + disabled: false, + stickiness: 'sessionId', + weights: { Control: 33.4, VariantB: 33.3, VariantC: 33.3 }, + }, + { + name: 'Fallback', + constraints: [], + segments: [], + disabled: false, + stickiness: 'sessionId', + weights: { Control: 25, VariantB: 50, VariantC: 25 }, + }, + ]; + + expect( + resolvePreviewTreatment( + treatments, + lanes, + { appName: 'hosted', sessionId: 'xagzxbasdffwer' }, + { groupId: 'abtest' }, + ).lane?.name, + ).toBe('Hosted apps'); + expect( + resolvePreviewTreatment( + treatments, + lanes, + { appName: 'other', sessionId: 'xagzxbasdffwer' }, + { groupId: 'abtest' }, + ).lane?.name, + ).toBe('Fallback'); + }); + + test('validates variant keys, lane totals, and derives property names', () => { + const treatments: ExperimentTreatment[] = [ + { + name: 'Control', + label: 'Control', + properties: { headline: 'Buy now' }, + }, + { + name: 'VariantB', + label: 'VariantB', + properties: { imageUrl: 'https://example.com' }, + }, + ]; + expect(createTreatmentKey('Homepage hero / Blue CTA')).toBe( + 'Homepage-hero-Blue-CTA', + ); + expect(getExperimentPropertyNames(treatments)).toEqual([ + 'headline', + 'imageUrl', + ]); + expect( + experimentIsValid(treatments, [ + { + name: 'Fallback', + constraints: [], + segments: [], + disabled: false, + stickiness: 'userId', + weights: { Control: 50, VariantB: 50 }, + }, + ]), + ).toBe(true); + expect( + experimentIsValid(treatments, [ + { + name: 'Fallback', + constraints: [], + segments: [], + disabled: false, + stickiness: 'userId', + weights: { Control: 40, VariantB: 50 }, + }, + ]), + ).toBe(false); + }); +}); diff --git a/frontend/src/component/feature/FeatureView/FeatureExperiment/experimentStrategy.ts b/frontend/src/component/feature/FeatureView/FeatureExperiment/experimentStrategy.ts new file mode 100644 index 0000000000..dc5a04e3bf --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureExperiment/experimentStrategy.ts @@ -0,0 +1,509 @@ +import murmurHash3 from 'murmurhash3js'; +import { + IN, + NOT_IN, + NUM_EQ, + NUM_GT, + NUM_GTE, + NUM_LT, + NUM_LTE, + STR_CONTAINS, + STR_ENDS_WITH, + STR_STARTS_WITH, + type Operator, +} from 'constants/operators'; +import type { + IFeatureEnvironment, + IFeatureVariant, +} from 'interfaces/featureToggle'; +import type { + IConstraint, + IFeatureStrategy, + IFeatureStrategyPayload, +} from 'interfaces/strategy'; + +export const EXPERIMENT_STRATEGY = 'flexibleRollout'; +export const DEFAULT_CONTROL_NAME = 'Control'; +const VARIANT_NAME_PATTERN = /^[A-Za-z0-9._-]+$/; + +export type TreatmentConstraint = Pick< + IConstraint, + | 'contextName' + | 'operator' + | 'values' + | 'value' + | 'inverted' + | 'caseInsensitive' +>; + +export type ExperimentTreatment = { + name: string; + label: string; + properties: Record; +}; + +export type ExperimentLane = { + id?: string; + name: string; + constraints: TreatmentConstraint[]; + segments: number[]; + disabled: boolean; + stickiness: string; + weights: Record; +}; + +export type ExperimentEnvironmentConfig = { + environment: IFeatureEnvironment; + strategies: IFeatureStrategy[]; + hasAdvancedConfiguration: boolean; + configured: boolean; + disabled: boolean; + stickiness: string; + treatments: ExperimentTreatment[]; + lanes: ExperimentLane[]; +}; + +export const createDefaultTreatments = (): ExperimentTreatment[] => [ + { + name: 'Control', + label: DEFAULT_CONTROL_NAME, + properties: {}, + }, + { + name: 'VariantB', + label: 'VariantB', + properties: {}, + }, +]; + +const createDefaultWeights = ( + treatments: ExperimentTreatment[], +): Record => { + if (treatments.length === 0) return {}; + + const treatmentWeight = Math.floor(1000 / treatments.length / 1) / 10; + const weights = Object.fromEntries( + treatments.map((treatment) => [treatment.name, treatmentWeight]), + ); + weights[treatments[0].name] = + Math.round( + (100 - + Object.entries(weights) + .slice(1) + .reduce((sum, [, weight]) => sum + weight, 0)) * + 10, + ) / 10; + + return weights; +}; + +export const createDefaultLanes = ( + treatments = createDefaultTreatments(), +): ExperimentLane[] => [ + { + name: 'All traffic', + constraints: [], + segments: [], + disabled: false, + stickiness: 'default', + weights: createDefaultWeights(treatments), + }, +]; + +const toPercent = (weight: number | undefined): number => + Math.round(weight ?? 0) / 10; + +const toVariantWeight = (weight: number): number => Math.round(weight * 10); + +export const createTreatmentKey = (label: string): string => { + const key = label + .trim() + .replace(/[^A-Za-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, ''); + + return key || 'variant'; +}; + +type ParsedVariantPayload = Pick; + +const stringifyPayloadValue = (value: unknown): string => { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + return JSON.stringify(value); +}; + +const parseVariantPayload = ( + variant: IFeatureVariant, +): ParsedVariantPayload => { + if (variant.payload?.type !== 'json' || !variant.payload.value) { + return { label: variant.name, properties: {} }; + } + + try { + const parsed = JSON.parse(variant.payload.value); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const payloadObject = parsed as Record; + const properties = + payloadObject.properties && + typeof payloadObject.properties === 'object' && + !Array.isArray(payloadObject.properties) + ? (payloadObject.properties as Record) + : payloadObject; + + return { + label: + typeof payloadObject.label === 'string' + ? payloadObject.label + : variant.name, + properties: Object.fromEntries( + Object.entries(properties) + .filter( + ([key]) => key !== 'label' && key !== 'constraints', + ) + .map(([key, value]) => [ + key, + stringifyPayloadValue(value), + ]), + ), + }; + } + } catch (_error) { + return { label: variant.name, properties: {} }; + } + + return { label: variant.name, properties: {} }; +}; + +const createVariantPayload = ( + treatment: ExperimentTreatment, +): IFeatureVariant['payload'] | undefined => { + const properties = Object.fromEntries( + Object.entries(treatment.properties) + .map(([key, value]) => [key.trim(), value.trim()]) + .filter(([key, value]) => key && value), + ); + + if (Object.keys(properties).length === 0) { + return undefined; + } + + return { + type: 'json', + value: JSON.stringify(properties), + }; +}; + +const collectTreatments = ( + strategies: IFeatureStrategy[], +): ExperimentTreatment[] => { + const treatments = new Map(); + + strategies.forEach((strategy) => { + (strategy.variants ?? []).forEach((variant) => { + if (treatments.has(variant.name)) return; + + const payload = parseVariantPayload(variant); + treatments.set(variant.name, { + name: variant.name, + label: payload.label, + properties: payload.properties, + }); + }); + }); + + return Array.from(treatments.values()); +}; + +const strategyToLane = ( + strategy: IFeatureStrategy, + index: number, + treatments: ExperimentTreatment[], +): ExperimentLane => { + const defaultName = + strategy.constraints?.length || strategy.segments?.length + ? `Lane ${index + 1}` + : 'All traffic'; + + return { + id: strategy.id, + name: + strategy.title && strategy.title !== 'Experiment' + ? strategy.title + : defaultName, + constraints: (strategy.constraints ?? []) as TreatmentConstraint[], + segments: strategy.segments ?? [], + disabled: strategy.disabled ?? false, + stickiness: String(strategy.parameters?.stickiness || 'default'), + weights: Object.fromEntries( + treatments.map((treatment) => [ + treatment.name, + toPercent( + strategy.variants?.find( + (variant) => variant.name === treatment.name, + )?.weight, + ), + ]), + ), + }; +}; + +export const resolveExperimentConfig = ( + environment: IFeatureEnvironment, +): ExperimentEnvironmentConfig => { + const strategies = environment.strategies ?? []; + const experimentStrategies = strategies + .filter((strategy) => strategy.name === EXPERIMENT_STRATEGY) + .toSorted((strategyA, strategyB) => { + const strategyAIsFallback = + (strategyA.constraints?.length ?? 0) === 0 && + (strategyA.segments?.length ?? 0) === 0; + const strategyBIsFallback = + (strategyB.constraints?.length ?? 0) === 0 && + (strategyB.segments?.length ?? 0) === 0; + + if (strategyAIsFallback !== strategyBIsFallback) { + return strategyAIsFallback ? 1 : -1; + } + + return (strategyA.sortOrder ?? 0) - (strategyB.sortOrder ?? 0); + }); + const treatmentsFromStrategies = collectTreatments(experimentStrategies); + const treatments = + treatmentsFromStrategies.length > 0 + ? treatmentsFromStrategies + : createDefaultTreatments(); + const lanes = + experimentStrategies.length > 0 + ? experimentStrategies.map((strategy, index) => + strategyToLane(strategy, index, treatments), + ) + : createDefaultLanes(treatments); + + return { + environment, + strategies: experimentStrategies, + hasAdvancedConfiguration: strategies.some( + (strategy) => strategy.name !== EXPERIMENT_STRATEGY, + ), + configured: experimentStrategies.length > 0, + disabled: lanes.every((lane) => lane.disabled), + stickiness: lanes[0]?.stickiness ?? 'default', + treatments, + lanes, + }; +}; + +export const createExperimentStrategyPayload = ({ + featureId, + lane, + treatments, + sortOrder, +}: { + featureId: string; + lane: ExperimentLane; + treatments: ExperimentTreatment[]; + sortOrder?: number; +}): IFeatureStrategyPayload => ({ + name: EXPERIMENT_STRATEGY, + title: lane.name.trim() || 'Experiment', + sortOrder, + constraints: lane.constraints as IConstraint[], + segments: lane.segments, + disabled: false, + parameters: { + rollout: '100', + stickiness: lane.stickiness, + groupId: featureId, + }, + variants: treatments.map((treatment, index) => ({ + name: treatment.name.trim(), + stickiness: lane.stickiness, + weight: toVariantWeight(lane.weights[treatment.name] ?? 0), + weightType: index === 0 ? 'variable' : 'fix', + payload: createVariantPayload(treatment), + })), +}); + +export const laneTotal = (lane: ExperimentLane): number => + Object.values(lane.weights).reduce((sum, weight) => sum + weight, 0); + +export const experimentIsValid = ( + treatments: ExperimentTreatment[], + lanes: ExperimentLane[], +): boolean => { + const names = treatments.map((treatment) => treatment.name.trim()); + return ( + treatments.length >= 2 && + lanes.length >= 1 && + lanes.every((lane) => laneTotal(lane) === 100) && + names.every(Boolean) && + names.every((name) => VARIANT_NAME_PATTERN.test(name)) && + new Set(names).size === names.length + ); +}; + +export const getExperimentPropertyNames = ( + treatments: ExperimentTreatment[], +): string[] => { + const propertyNames = treatments.flatMap((treatment) => + Object.keys(treatment.properties), + ); + + return Array.from(new Set(propertyNames)); +}; + +export const getExperimentContextFields = ( + lanes: ExperimentLane[], +): string[] => { + const contextFields = lanes.flatMap((lane) => + lane.constraints.map((constraint) => constraint.contextName), + ); + + return Array.from(new Set(contextFields.filter(Boolean))); +}; + +const constraintValues = (constraint: TreatmentConstraint): string[] => { + if (constraint.values?.length) return constraint.values; + if (constraint.value) return [constraint.value]; + return []; +}; + +const matchesConstraint = ( + constraint: TreatmentConstraint, + context: Record, +): boolean => { + const actual = context[constraint.contextName] ?? ''; + const values = constraintValues(constraint); + const matches = (() => { + switch (constraint.operator as Operator) { + case IN: + return values.includes(actual); + case NOT_IN: + return !values.includes(actual); + case STR_CONTAINS: + return values.some((value) => actual.includes(value)); + case STR_STARTS_WITH: + return values.some((value) => actual.startsWith(value)); + case STR_ENDS_WITH: + return values.some((value) => actual.endsWith(value)); + case NUM_EQ: + return values.some((value) => Number(actual) === Number(value)); + case NUM_GT: + return values.some((value) => Number(actual) > Number(value)); + case NUM_GTE: + return values.some((value) => Number(actual) >= Number(value)); + case NUM_LT: + return values.some((value) => Number(actual) < Number(value)); + case NUM_LTE: + return values.some((value) => Number(actual) <= Number(value)); + default: + return values.includes(actual); + } + })(); + + return constraint.inverted ? !matches : matches; +}; + +export const laneMatchesContext = ( + lane: ExperimentLane, + context: Record, +): boolean => + lane.constraints.every((constraint) => + matchesConstraint(constraint, context), + ); + +const VARIANT_SEED = 86028157; + +const normalizedVariantValue = ( + id: string, + groupId: string, + normalizer: number, +): number => { + const hash = murmurHash3.x86.hash32(`${groupId}:${id}`, VARIANT_SEED); + return (hash % normalizer) + 1; +}; + +const previewStickinessValue = ( + context: Record, + stickiness: string, +): string => { + if (stickiness && stickiness !== 'default' && stickiness !== 'random') { + return ( + context[stickiness] || + context.userId || + context.sessionId || + 'preview' + ); + } + + return ( + context.userId || + context.sessionId || + context.remoteAddress || + 'preview' + ); +}; + +const selectWeightedTreatment = ( + treatments: ExperimentTreatment[], + lane: ExperimentLane, + context: Record, + groupId: string, +): ExperimentTreatment | undefined => { + const weightedTreatments = treatments.filter( + (treatment) => (lane.weights[treatment.name] ?? 0) > 0, + ); + if (weightedTreatments.length <= 1) return weightedTreatments[0]; + + const totalWeight = weightedTreatments.reduce( + (sum, treatment) => sum + (lane.weights[treatment.name] ?? 0), + 0, + ); + const normalizer = Math.round(totalWeight * 10); + if (normalizer <= 0) return weightedTreatments[0]; + + const bucket = normalizedVariantValue( + previewStickinessValue(context, lane.stickiness), + groupId, + normalizer, + ); + let accumulatedWeight = 0; + + return ( + weightedTreatments.find((treatment) => { + accumulatedWeight += Math.round( + (lane.weights[treatment.name] ?? 0) * 10, + ); + return bucket <= accumulatedWeight; + }) ?? weightedTreatments.at(-1) + ); +}; + +export const resolvePreviewTreatment = ( + treatments: ExperimentTreatment[], + lanes: ExperimentLane[], + context: Record, + options: { groupId?: string } = {}, +): { lane?: ExperimentLane; treatment?: ExperimentTreatment } => { + const groupId = options.groupId ?? 'experiment-preview'; + const matchedLane = + lanes.find( + (lane) => + lane.constraints.length > 0 && + laneMatchesContext(lane, context), + ) ?? + lanes.find( + (lane) => + lane.constraints.length === 0 && + laneMatchesContext(lane, context), + ); + + return { + lane: matchedLane, + treatment: matchedLane + ? selectWeightedTreatment(treatments, matchedLane, context, groupId) + : undefined, + }; +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureView.tsx b/frontend/src/component/feature/FeatureView/FeatureView.tsx index e5ec9fad84..ffa5def37b 100644 --- a/frontend/src/component/feature/FeatureView/FeatureView.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureView.tsx @@ -15,6 +15,7 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { FeatureImpactHeader } from './FeatureImpactOverview/FeatureImpactHeader'; import { ImpactMetricModal } from '../../impact-metrics/ImpactMetricModal/ImpactMetricModal'; import { useFeatureImpactChartActions } from './useFeatureImpactChartActions'; +import { FeatureExperimentOverview } from './FeatureExperiment/FeatureExperimentOverview.tsx'; export const StyledLink = styled(Link)(() => ({ maxWidth: '100%', @@ -32,7 +33,7 @@ export const FeatureView = () => { const { isEnterprise } = useUiConfig(); const showImpactMetrics = impactMetricsFlagPage && isEnterprise(); - const { feature, loading, error, status } = useFeature( + const { feature, loading, error, status, refetchFeature } = useFeature( projectId, featureId, ); @@ -56,6 +57,26 @@ export const FeatureView = () => { return
; } + const overview = + feature.type === 'experiment' ? ( + + ) : ( + + ) : undefined + } + /> + ); + return (
@@ -67,22 +88,7 @@ export const FeatureView = () => { element={} /> } /> - - ) : undefined - } - /> - } - /> + {showImpactMetrics && (