From 42f3ba5fc2d70294a9184a7ddd63e2de44dcc4fa Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Mon, 12 May 2025 15:27:31 +0300 Subject: [PATCH] feat: add input box for gradual rollout slider (#9960) --- .../FlexibleStrategy/FlexibleStrategy.tsx | 4 +- .../ConditionalRolloutSlider.tsx | 24 +++ .../RolloutSlider/LegacyRolloutSlider.tsx | 162 ++++++++++++++++++ .../RolloutSlider/RolloutSlider.tsx | 126 +++++++++++--- .../StrategyParameter/StrategyParameter.tsx | 4 +- .../MilestoneStrategyTypeFlexible.tsx | 4 +- 6 files changed, 292 insertions(+), 32 deletions(-) create mode 100644 frontend/src/component/feature/StrategyTypes/RolloutSlider/ConditionalRolloutSlider.tsx create mode 100644 frontend/src/component/feature/StrategyTypes/RolloutSlider/LegacyRolloutSlider.tsx diff --git a/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy.tsx b/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy.tsx index 1f1b956c2f..9236e15835 100644 --- a/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy.tsx +++ b/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo } from 'react'; import { Box, styled } from '@mui/material'; import type { IFeatureStrategyParameters } from 'interfaces/strategy'; -import RolloutSlider from '../RolloutSlider/RolloutSlider'; +import ConditionalRolloutSlider from '../RolloutSlider/ConditionalRolloutSlider'; import Input from 'component/common/Input/Input'; import { FLEXIBLE_STRATEGY_GROUP_ID, @@ -100,7 +100,7 @@ const FlexibleStrategy = ({ return ( - void; + disabled?: boolean; +} + +const ConditionalRolloutSlider = (props: IRolloutSliderProps) => { + const addEditStrategy = useUiFlag('addEditStrategy'); + + if (addEditStrategy) { + return ; + } + + return ; +}; + +export default ConditionalRolloutSlider; diff --git a/frontend/src/component/feature/StrategyTypes/RolloutSlider/LegacyRolloutSlider.tsx b/frontend/src/component/feature/StrategyTypes/RolloutSlider/LegacyRolloutSlider.tsx new file mode 100644 index 0000000000..f195734d35 --- /dev/null +++ b/frontend/src/component/feature/StrategyTypes/RolloutSlider/LegacyRolloutSlider.tsx @@ -0,0 +1,162 @@ +import { makeStyles, withStyles } from 'tss-react/mui'; +import { Slider, Typography, Box, styled } from '@mui/material'; +import { ROLLOUT_SLIDER_ID } from 'utils/testIds'; +import { HelpIcon } from 'component/common/HelpIcon/HelpIcon'; + +const StyledSlider = withStyles(Slider, (theme) => ({ + root: { + height: 8, + }, + thumb: { + height: 24, + width: 24, + backgroundColor: theme.palette.background.paper, + border: '2px solid currentColor', + }, + active: {}, + valueLabel: {}, + track: { + height: 8, + borderRadius: theme.shape.borderRadius, + }, + rail: { + height: 8, + borderRadius: theme.shape.borderRadius, + }, +})); + +const StyledHeader = styled(Typography)(({ theme }) => ({ + marginBottom: theme.spacing(1), +})); + +const StyledSubheader = styled(Typography)(({ theme }) => ({ + marginBottom: theme.spacing(1), + marginTop: theme.spacing(1), +})); + +const StyledBox = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + marginBottom: theme.spacing(1), +})); + +const useStyles = makeStyles()((theme) => ({ + slider: { + width: '100%', + maxWidth: '100%', + }, + margin: { + height: theme.spacing(3), + }, +})); + +const marks = [ + { + value: 0, + label: '0%', + }, + { + value: 25, + label: '25%', + }, + { + value: 50, + label: '50%', + }, + { + value: 75, + label: '75%', + }, + { + value: 100, + label: '100%', + }, +]; + +interface IRolloutSliderProps { + name: string; + minLabel?: string; + maxLabel?: string; + value: number; + onChange: (e: Event, newValue: number | number[]) => void; + disabled?: boolean; +} + +const LegacyRolloutSlider = ({ + name, + value, + onChange, + disabled = false, +}: IRolloutSliderProps) => { + const { classes } = useStyles(); + + const valuetext = (value: number) => `${value}%`; + + return ( +
+ + {name} + + + Rollout percentage + + + The rollout percentage determines the proportion + of users exposed to a feature. It's based on the + MurmurHash of a user's unique identifier, + normalized to a number between 1 and 100. If the + normalized hash is less than or equal to the + rollout percentage, the user sees the feature. + This ensures a consistent, random distribution + of the feature among users. + + + + Stickiness + + + Stickiness refers to the value used for hashing + to ensure a consistent user experience. It + determines the input for the MurmurHash, + ensuring that a user's feature exposure remains + consistent across sessions. +
+ By default Unleash will use the first value + present in the context in the order of{' '} + userId, sessionId and random. +
+ + + GroupId + + + The groupId is used as a seed for the hash + function, ensuring consistent feature exposure + across different feature flags for a uniform + user experience. + + + } + /> +
+ +
+ ); +}; + +export default LegacyRolloutSlider; diff --git a/frontend/src/component/feature/StrategyTypes/RolloutSlider/RolloutSlider.tsx b/frontend/src/component/feature/StrategyTypes/RolloutSlider/RolloutSlider.tsx index 87da7478f7..3860da6ac3 100644 --- a/frontend/src/component/feature/StrategyTypes/RolloutSlider/RolloutSlider.tsx +++ b/frontend/src/component/feature/StrategyTypes/RolloutSlider/RolloutSlider.tsx @@ -1,7 +1,15 @@ -import { makeStyles, withStyles } from 'tss-react/mui'; -import { Slider, Typography, Box, styled } from '@mui/material'; +import { withStyles } from 'tss-react/mui'; +import { + Slider, + Typography, + Box, + styled, + TextField, + InputAdornment, +} from '@mui/material'; import { ROLLOUT_SLIDER_ID } from 'utils/testIds'; import { HelpIcon } from 'component/common/HelpIcon/HelpIcon'; +import { useState, useEffect } from 'react'; const StyledSlider = withStyles(Slider, (theme) => ({ root: { @@ -37,17 +45,38 @@ const StyledSubheader = styled(Typography)(({ theme }) => ({ const StyledBox = styled(Box)(({ theme }) => ({ display: 'flex', alignItems: 'center', + marginBottom: theme.spacing(2), +})); + +const StyledSliderContainer = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + width: '100%', + gap: theme.spacing(4), marginBottom: theme.spacing(1), })); -const useStyles = makeStyles()((theme) => ({ - slider: { - width: '100%', - maxWidth: '100%', - }, - margin: { - height: theme.spacing(3), - }, +const StyledInputBox = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + width: '100px', + flexShrink: 0, +})); + +const SliderWrapper = styled('div')(({ theme }) => ({ + width: '100%', + maxWidth: '100%', + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), +})); + +const SliderContent = styled('div')(({ theme }) => ({ + flexGrow: 1, +})); + +const StyledTextField = styled(TextField)(({ theme }) => ({ + width: '90px', })); const marks = [ @@ -88,12 +117,30 @@ const RolloutSlider = ({ onChange, disabled = false, }: IRolloutSliderProps) => { - const { classes } = useStyles(); + const [inputValue, setInputValue] = useState(value.toString()); + + useEffect(() => { + setInputValue(value.toString()); + }, [value]); const valuetext = (value: number) => `${value}%`; + const handleInputChange = (e: React.ChangeEvent) => { + setInputValue(e.target.value); + }; + + const handleInputBlur = () => { + const numValue = Number.parseInt(inputValue, 10); + if (!Number.isNaN(numValue) && numValue >= 0 && numValue <= 100) { + const event = new Event('input', { bubbles: true }); + onChange(event, numValue); + } else { + setInputValue(value.toString()); + } + }; + return ( -
+ {name} - -
+ + + + + + + % + + ), + }} + /> + + + ); }; diff --git a/frontend/src/component/feature/StrategyTypes/StrategyParameter/StrategyParameter.tsx b/frontend/src/component/feature/StrategyTypes/StrategyParameter/StrategyParameter.tsx index 88baa64ac8..bdb69f163e 100644 --- a/frontend/src/component/feature/StrategyTypes/StrategyParameter/StrategyParameter.tsx +++ b/frontend/src/component/feature/StrategyTypes/StrategyParameter/StrategyParameter.tsx @@ -1,7 +1,7 @@ import type React from 'react'; import { FormControlLabel, Switch, TextField } from '@mui/material'; import StrategyInputList from '../StrategyInputList/StrategyInputList'; -import RolloutSlider from '../RolloutSlider/RolloutSlider'; +import ConditionalRolloutSlider from '../RolloutSlider/ConditionalRolloutSlider'; import type { IFeatureStrategyParameters, IStrategyParameter, @@ -57,7 +57,7 @@ export const StrategyParameter = ({ if (type === 'percentage') { return (
- -