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:
parent
148e6e0da8
commit
42f3ba5fc2
@ -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}
|
||||
|
@ -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 { 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
Loading…
Reference in New Issue
Block a user