1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-19 17:52:45 +02:00

feat: add input box for gradual rollout slider (#9960)

This commit is contained in:
Jaanus Sellin 2025-05-12 15:27:31 +03:00 committed by GitHub
parent 148e6e0da8
commit 42f3ba5fc2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 292 additions and 32 deletions

View File

@ -1,7 +1,7 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { Box, styled } from '@mui/material'; import { Box, styled } from '@mui/material';
import type { IFeatureStrategyParameters } from 'interfaces/strategy'; import type { IFeatureStrategyParameters } from 'interfaces/strategy';
import RolloutSlider from '../RolloutSlider/RolloutSlider'; import ConditionalRolloutSlider from '../RolloutSlider/ConditionalRolloutSlider';
import Input from 'component/common/Input/Input'; import Input from 'component/common/Input/Input';
import { import {
FLEXIBLE_STRATEGY_GROUP_ID, FLEXIBLE_STRATEGY_GROUP_ID,
@ -100,7 +100,7 @@ const FlexibleStrategy = ({
return ( return (
<StyledBox> <StyledBox>
<RolloutSlider <ConditionalRolloutSlider
name='Rollout' name='Rollout'
value={rollout} value={rollout}
disabled={!editable} disabled={!editable}

View File

@ -0,0 +1,24 @@
import RolloutSlider from './RolloutSlider';
import LegacyRolloutSlider from './LegacyRolloutSlider';
import { useUiFlag } from 'hooks/useUiFlag';
interface IRolloutSliderProps {
name: string;
minLabel?: string;
maxLabel?: string;
value: number;
onChange: (e: Event, newValue: number | number[]) => void;
disabled?: boolean;
}
const ConditionalRolloutSlider = (props: IRolloutSliderProps) => {
const addEditStrategy = useUiFlag('addEditStrategy');
if (addEditStrategy) {
return <RolloutSlider {...props} />;
}
return <LegacyRolloutSlider {...props} />;
};
export default ConditionalRolloutSlider;

View File

@ -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 (
<div className={classes.slider}>
<StyledBox>
<Typography id='discrete-slider-always'>{name}</Typography>
<HelpIcon
htmlTooltip
tooltip={
<Box>
<StyledHeader variant='h3'>
Rollout percentage
</StyledHeader>
<Typography variant='body2'>
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.
</Typography>
<StyledSubheader variant='h3'>
Stickiness
</StyledSubheader>
<Typography variant='body2'>
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.
<br />
By default Unleash will use the first value
present in the context in the order of{' '}
<b>userId, sessionId and random</b>.
</Typography>
<StyledSubheader variant='h3'>
GroupId
</StyledSubheader>
<Typography variant='body2'>
The groupId is used as a seed for the hash
function, ensuring consistent feature exposure
across different feature flags for a uniform
user experience.
</Typography>
</Box>
}
/>
</StyledBox>
<StyledSlider
min={0}
max={100}
value={value}
getAriaValueText={valuetext}
aria-labelledby='discrete-slider-always'
step={1}
data-testid={ROLLOUT_SLIDER_ID}
marks={marks}
onChange={onChange}
valueLabelDisplay='on'
disabled={disabled}
/>
</div>
);
};
export default LegacyRolloutSlider;

View File

@ -1,7 +1,15 @@
import { makeStyles, withStyles } from 'tss-react/mui'; import { withStyles } from 'tss-react/mui';
import { Slider, Typography, Box, styled } from '@mui/material'; import {
Slider,
Typography,
Box,
styled,
TextField,
InputAdornment,
} from '@mui/material';
import { ROLLOUT_SLIDER_ID } from 'utils/testIds'; import { ROLLOUT_SLIDER_ID } from 'utils/testIds';
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon'; import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
import { useState, useEffect } from 'react';
const StyledSlider = withStyles(Slider, (theme) => ({ const StyledSlider = withStyles(Slider, (theme) => ({
root: { root: {
@ -37,17 +45,38 @@ const StyledSubheader = styled(Typography)(({ theme }) => ({
const StyledBox = styled(Box)(({ theme }) => ({ const StyledBox = styled(Box)(({ theme }) => ({
display: 'flex', display: 'flex',
alignItems: 'center', 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), marginBottom: theme.spacing(1),
})); }));
const useStyles = makeStyles()((theme) => ({ const StyledInputBox = styled(Box)(({ theme }) => ({
slider: { display: 'flex',
alignItems: 'center',
width: '100px',
flexShrink: 0,
}));
const SliderWrapper = styled('div')(({ theme }) => ({
width: '100%', width: '100%',
maxWidth: '100%', maxWidth: '100%',
}, display: 'flex',
margin: { flexDirection: 'column',
height: theme.spacing(3), gap: theme.spacing(2),
}, }));
const SliderContent = styled('div')(({ theme }) => ({
flexGrow: 1,
}));
const StyledTextField = styled(TextField)(({ theme }) => ({
width: '90px',
})); }));
const marks = [ const marks = [
@ -88,12 +117,30 @@ const RolloutSlider = ({
onChange, onChange,
disabled = false, disabled = false,
}: IRolloutSliderProps) => { }: IRolloutSliderProps) => {
const { classes } = useStyles(); const [inputValue, setInputValue] = useState(value.toString());
useEffect(() => {
setInputValue(value.toString());
}, [value]);
const valuetext = (value: number) => `${value}%`; const valuetext = (value: number) => `${value}%`;
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 ( return (
<div className={classes.slider}> <SliderWrapper>
<StyledBox> <StyledBox>
<Typography id='discrete-slider-always'>{name}</Typography> <Typography id='discrete-slider-always'>{name}</Typography>
<HelpIcon <HelpIcon
@ -142,6 +189,8 @@ const RolloutSlider = ({
} }
/> />
</StyledBox> </StyledBox>
<StyledSliderContainer>
<SliderContent>
<StyledSlider <StyledSlider
min={0} min={0}
max={100} max={100}
@ -155,7 +204,32 @@ const RolloutSlider = ({
valueLabelDisplay='on' valueLabelDisplay='on'
disabled={disabled} disabled={disabled}
/> />
</div> </SliderContent>
<StyledInputBox>
<StyledTextField
size='small'
aria-labelledby='discrete-slider-always'
type='number'
value={inputValue}
onChange={handleInputChange}
onBlur={handleInputBlur}
disabled={disabled}
inputProps={{
min: 0,
max: 100,
step: 1,
}}
InputProps={{
endAdornment: (
<InputAdornment position='end'>
%
</InputAdornment>
),
}}
/>
</StyledInputBox>
</StyledSliderContainer>
</SliderWrapper>
); );
}; };

View File

@ -1,7 +1,7 @@
import type React from 'react'; import type React from 'react';
import { FormControlLabel, Switch, TextField } from '@mui/material'; import { FormControlLabel, Switch, TextField } from '@mui/material';
import StrategyInputList from '../StrategyInputList/StrategyInputList'; import StrategyInputList from '../StrategyInputList/StrategyInputList';
import RolloutSlider from '../RolloutSlider/RolloutSlider'; import ConditionalRolloutSlider from '../RolloutSlider/ConditionalRolloutSlider';
import type { import type {
IFeatureStrategyParameters, IFeatureStrategyParameters,
IStrategyParameter, IStrategyParameter,
@ -57,7 +57,7 @@ export const StrategyParameter = ({
if (type === 'percentage') { if (type === 'percentage') {
return ( return (
<div> <div>
<RolloutSlider <ConditionalRolloutSlider
name={name} name={name}
onChange={onChangePercentage} onChange={onChangePercentage}
disabled={!editable} disabled={!editable}

View File

@ -1,6 +1,6 @@
import { Box, styled } from '@mui/material'; import { Box, styled } from '@mui/material';
import { StickinessSelect } from 'component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect'; import { StickinessSelect } from 'component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect';
import RolloutSlider from 'component/feature/StrategyTypes/RolloutSlider/RolloutSlider'; import ConditionalRolloutSlider from '../../../../feature/StrategyTypes/RolloutSlider/ConditionalRolloutSlider';
import type { IFormErrors } from 'hooks/useFormErrors'; import type { IFormErrors } from 'hooks/useFormErrors';
import type { IFeatureStrategyParameters } from 'interfaces/strategy'; import type { IFeatureStrategyParameters } from 'interfaces/strategy';
import { useMemo } from 'react'; import { useMemo } from 'react';
@ -79,7 +79,7 @@ export const MilestoneStrategyTypeFlexible = ({
return ( return (
<StyledBox> <StyledBox>
<RolloutSlider <ConditionalRolloutSlider
name='Rollout' name='Rollout'
value={rollout} value={rollout}
disabled={!editable} disabled={!editable}