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:
parent
148e6e0da8
commit
42f3ba5fc2
@ -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}
|
||||||
|
@ -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;
|
@ -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;
|
@ -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',
|
||||||
width: '100%',
|
alignItems: 'center',
|
||||||
maxWidth: '100%',
|
width: '100px',
|
||||||
},
|
flexShrink: 0,
|
||||||
margin: {
|
}));
|
||||||
height: theme.spacing(3),
|
|
||||||
},
|
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 = [
|
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,20 +189,47 @@ const RolloutSlider = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</StyledBox>
|
</StyledBox>
|
||||||
<StyledSlider
|
<StyledSliderContainer>
|
||||||
min={0}
|
<SliderContent>
|
||||||
max={100}
|
<StyledSlider
|
||||||
value={value}
|
min={0}
|
||||||
getAriaValueText={valuetext}
|
max={100}
|
||||||
aria-labelledby='discrete-slider-always'
|
value={value}
|
||||||
step={1}
|
getAriaValueText={valuetext}
|
||||||
data-testid={ROLLOUT_SLIDER_ID}
|
aria-labelledby='discrete-slider-always'
|
||||||
marks={marks}
|
step={1}
|
||||||
onChange={onChange}
|
data-testid={ROLLOUT_SLIDER_ID}
|
||||||
valueLabelDisplay='on'
|
marks={marks}
|
||||||
disabled={disabled}
|
onChange={onChange}
|
||||||
/>
|
valueLabelDisplay='on'
|
||||||
</div>
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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}
|
||||||
|
@ -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}
|
||||||
|
Loading…
Reference in New Issue
Block a user