From 9044d4c537c13a23f3a8348924a4d893c5af2b52 Mon Sep 17 00:00:00 2001 From: David Leek Date: Wed, 27 Nov 2024 13:32:17 +0100 Subject: [PATCH] feat: add variants to release plan template strategies (#8870) --- .../MilestoneStrategyVariants.tsx | 189 ++++++++++++++++++ .../ReleasePlanTemplateAddStrategyForm.tsx | 45 ++++- 2 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyVariants.tsx diff --git a/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyVariants.tsx b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyVariants.tsx new file mode 100644 index 000000000000..c0639ef3e874 --- /dev/null +++ b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyVariants.tsx @@ -0,0 +1,189 @@ +import type { IReleasePlanMilestoneStrategy } from 'interfaces/releasePlans'; +import { useEffect, useState } from 'react'; +import { Box, styled, Typography, Button } from '@mui/material'; +import { HelpIcon } from '../../common/HelpIcon/HelpIcon'; +import { StrategyVariantsUpgradeAlert } from 'component/common/StrategyVariantsUpgradeAlert/StrategyVariantsUpgradeAlert'; +import { VariantForm } from 'component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm/VariantForm'; +import { v4 as uuidv4 } from 'uuid'; +import type { IFeatureVariantEdit } from 'component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/EnvironmentVariantsModal'; +import { updateWeightEdit } from 'component/common/util'; +import { WeightType } from 'constants/variantTypes'; +import { useTheme } from '@mui/material'; +import Add from '@mui/icons-material/Add'; +import SplitPreviewSlider from 'component/feature/StrategyTypes/SplitPreviewSlider/SplitPreviewSlider'; + +const StyledVariantForms = styled('div')({ + display: 'flex', + flexDirection: 'column', +}); + +const StyledHelpIconBox = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + marginTop: theme.spacing(1), + marginBottom: theme.spacing(1), +})); + +const StyledVariantsHeader = styled('div')(({ theme }) => ({ + color: theme.palette.text.secondary, + marginTop: theme.spacing(1.5), +})); + +interface IMilestoneStrategyVariantsProps { + strategy: Omit; + setStrategy: React.Dispatch< + React.SetStateAction> + >; +} + +export const MilestoneStrategyVariants = ({ + strategy, + setStrategy, +}: IMilestoneStrategyVariantsProps) => { + const initialVariants = (strategy.variants || []).map((variant) => ({ + ...variant, + new: true, + isValid: true, + id: uuidv4(), + overrides: [], + })); + const [variantsEdit, setVariantsEdit] = + useState(initialVariants); + + const stickiness = + strategy?.parameters && 'stickiness' in strategy?.parameters + ? String(strategy.parameters.stickiness) + : 'default'; + const theme = useTheme(); + + useEffect(() => { + return () => { + setStrategy((prev) => ({ + ...prev, + variants: variantsEdit.filter((variant) => + Boolean(variant.name), + ), + })); + }; + }, [JSON.stringify(variantsEdit)]); + + useEffect(() => { + setStrategy((prev) => ({ + ...prev, + variants: variantsEdit.map((variant) => ({ + stickiness, + name: variant.name, + weight: variant.weight, + payload: variant.payload, + weightType: variant.weightType, + })), + })); + }, [stickiness, JSON.stringify(variantsEdit)]); + + const updateVariant = (updatedVariant: IFeatureVariantEdit, id: string) => { + setVariantsEdit((prevVariants) => + updateWeightEdit( + prevVariants.map((prevVariant) => + prevVariant.id === id ? updatedVariant : prevVariant, + ), + 1000, + ), + ); + }; + + const addVariant = () => { + const id = uuidv4(); + setVariantsEdit((variantsEdit) => [ + ...variantsEdit, + { + name: '', + weightType: WeightType.VARIABLE, + weight: 0, + stickiness, + new: true, + isValid: false, + id, + }, + ]); + }; + + const variantWeightsError = + variantsEdit.reduce( + (acc, variant) => acc + (variant.weight || 0), + 0, + ) !== 1000; + + return ( + <> + + Variants enhance a feature flag by providing a version of the + feature to be enabled + + + Variants + + + Variants in feature toggling allow you to serve + different versions of a feature to different + users. This can be used for A/B testing, gradual + rollouts, and canary releases. Variants provide + a way to control the user experience at a + granular level, enabling you to test and + optimize different aspects of your features. + Read more about variants{' '} + + here + + + + } + /> + + + {variantsEdit.length > 0 && } + + {variantsEdit.map((variant, i) => ( + + updateVariant(updatedVariant, variant.id) + } + removeVariant={() => + setVariantsEdit((variantsEdit) => + updateWeightEdit( + variantsEdit.filter( + (v) => v.id !== variant.id, + ), + 1000, + ), + ) + } + decorationColor={ + theme.palette.variants[ + i % theme.palette.variants.length + ] + } + weightsError={variantWeightsError} + /> + ))} + + + + + ); +}; diff --git a/frontend/src/component/releases/ReleasePlanTemplate/ReleasePlanTemplateAddStrategyForm.tsx b/frontend/src/component/releases/ReleasePlanTemplate/ReleasePlanTemplateAddStrategyForm.tsx index 6f3b86ef06fd..de6be0769737 100644 --- a/frontend/src/component/releases/ReleasePlanTemplate/ReleasePlanTemplateAddStrategyForm.tsx +++ b/frontend/src/component/releases/ReleasePlanTemplate/ReleasePlanTemplateAddStrategyForm.tsx @@ -13,7 +13,7 @@ import { Badge } from 'component/common/Badge/Badge'; import FormTemplate from 'component/common/FormTemplate/FormTemplate'; import type { IReleasePlanMilestoneStrategy } from 'interfaces/releasePlans'; import type { ISegment } from 'interfaces/segment'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { BuiltInStrategies, formatStrategyName } from 'utils/strategyNames'; import { MilestoneStrategyTitle } from './MilestoneStrategyTitle'; import { MilestoneStrategyType } from './MilestoneStrategyType'; @@ -22,6 +22,7 @@ import { useFormErrors } from 'hooks/useFormErrors'; import produce from 'immer'; import { MilestoneStrategySegment } from './MilestoneStrategySegment'; import { MilestoneStrategyConstraints } from './MilestoneStrategyConstraints'; +import { MilestoneStrategyVariants } from './MilestoneStrategyVariants'; import { useConstraintsValidation } from 'hooks/api/getters/useConstraintsValidation/useConstraintsValidation'; const StyledCancelButton = styled(Button)(({ theme }) => ({ @@ -138,6 +139,28 @@ export const ReleasePlanTemplateAddStrategyForm = ({ const { strategyDefinition } = useStrategy(strategy?.name); const hasValidConstraints = useConstraintsValidation(strategy?.constraints); const errors = useFormErrors(); + const showVariants = Boolean( + addStrategy?.parameters && 'stickiness' in addStrategy?.parameters, + ); + + const stickiness = + addStrategy?.parameters && 'stickiness' in addStrategy?.parameters + ? String(addStrategy.parameters.stickiness) + : 'default'; + + useEffect(() => { + setAddStrategy((prev) => ({ + ...prev, + variants: (addStrategy.variants || []).map((variant) => ({ + stickiness, + name: variant.name, + weight: variant.weight, + payload: variant.payload, + weightType: variant.weightType, + })), + })); + }, [stickiness, JSON.stringify(addStrategy.variants)]); + if (!strategy || !addStrategy || !strategyDefinition) { return null; } @@ -153,6 +176,7 @@ export const ReleasePlanTemplateAddStrategyForm = ({ return constraintCount + segmentCount; }; + const validateParameter = (key: string, value: string) => true; const updateParameter = (name: string, value: string) => { setAddStrategy( produce((draft) => { @@ -165,6 +189,7 @@ export const ReleasePlanTemplateAddStrategyForm = ({ } draft.parameters = draft.parameters ?? {}; draft.parameters[name] = value; + validateParameter(name, value); }), ); }; @@ -220,6 +245,18 @@ export const ReleasePlanTemplateAddStrategyForm = ({ } /> + {showVariants && ( + + Variants + + {addStrategy?.variants?.length || 0} + + + } + /> + )} {activeTab === 0 && ( @@ -262,6 +299,12 @@ export const ReleasePlanTemplateAddStrategyForm = ({ )} + {activeTab === 2 && showVariants && ( + + )}