1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-04 13:48:56 +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 { 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 (
<StyledBox>
<RolloutSlider
<ConditionalRolloutSlider
name='Rollout'
value={rollout}
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 { 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<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 (
<div className={classes.slider}>
<SliderWrapper>
<StyledBox>
<Typography id='discrete-slider-always'>{name}</Typography>
<HelpIcon
@ -142,20 +189,47 @@ const RolloutSlider = ({
}
/>
</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>
<StyledSliderContainer>
<SliderContent>
<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}
/>
</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 { 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 (
<div>
<RolloutSlider
<ConditionalRolloutSlider
name={name}
onChange={onChangePercentage}
disabled={!editable}

View File

@ -1,6 +1,6 @@
import { Box, styled } from '@mui/material';
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 { IFeatureStrategyParameters } from 'interfaces/strategy';
import { useMemo } from 'react';
@ -79,7 +79,7 @@ export const MilestoneStrategyTypeFlexible = ({
return (
<StyledBox>
<RolloutSlider
<ConditionalRolloutSlider
name='Rollout'
value={rollout}
disabled={!editable}