mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-24 01:18:01 +02:00
feat: Strategy variants stickiness (#4250)
This commit is contained in:
parent
fb6e4906a7
commit
56d5579b89
@ -248,7 +248,11 @@ export const FeatureStrategyForm = ({
|
|||||||
/>
|
/>
|
||||||
<StyledHr />
|
<StyledHr />
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={Boolean(uiConfig?.flags?.strategyVariant)}
|
condition={
|
||||||
|
Boolean(uiConfig?.flags?.strategyVariant) &&
|
||||||
|
strategy.parameters != null &&
|
||||||
|
'stickiness' in strategy.parameters
|
||||||
|
}
|
||||||
show={
|
show={
|
||||||
<StrategyVariants
|
<StrategyVariants
|
||||||
strategy={strategy}
|
strategy={strategy}
|
||||||
|
@ -150,6 +150,7 @@ interface IVariantFormProps {
|
|||||||
updateVariant: (updatedVariant: IFeatureVariantEdit) => void;
|
updateVariant: (updatedVariant: IFeatureVariantEdit) => void;
|
||||||
removeVariant: (variantId: string) => void;
|
removeVariant: (variantId: string) => void;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
disableOverrides?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VariantForm = ({
|
export const VariantForm = ({
|
||||||
@ -158,6 +159,7 @@ export const VariantForm = ({
|
|||||||
updateVariant,
|
updateVariant,
|
||||||
removeVariant,
|
removeVariant,
|
||||||
error,
|
error,
|
||||||
|
disableOverrides = false,
|
||||||
}: IVariantFormProps) => {
|
}: IVariantFormProps) => {
|
||||||
const [name, setName] = useState(variant.name);
|
const [name, setName] = useState(variant.name);
|
||||||
const [customPercentage, setCustomPercentage] = useState(
|
const [customPercentage, setCustomPercentage] = useState(
|
||||||
@ -168,8 +170,9 @@ export const VariantForm = ({
|
|||||||
variant.payload || EMPTY_PAYLOAD
|
variant.payload || EMPTY_PAYLOAD
|
||||||
);
|
);
|
||||||
const [overrides, overridesDispatch] = useOverrides(
|
const [overrides, overridesDispatch] = useOverrides(
|
||||||
variant.overrides || []
|
'overrides' in variant ? variant.overrides || [] : []
|
||||||
);
|
);
|
||||||
|
|
||||||
const { context } = useUnleashContext();
|
const { context } = useUnleashContext();
|
||||||
|
|
||||||
const [errors, setErrors] = useState<IVariantFormErrors>({});
|
const [errors, setErrors] = useState<IVariantFormErrors>({});
|
||||||
@ -269,7 +272,7 @@ export const VariantForm = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateVariant({
|
const newVariant: IFeatureVariantEdit = {
|
||||||
...variant,
|
...variant,
|
||||||
name,
|
name,
|
||||||
weight: Number(customPercentage ? percentage : 100) * 10,
|
weight: Number(customPercentage ? percentage : 100) * 10,
|
||||||
@ -277,19 +280,22 @@ export const VariantForm = ({
|
|||||||
stickiness:
|
stickiness:
|
||||||
variants?.length > 0 ? variants[0].stickiness : 'default',
|
variants?.length > 0 ? variants[0].stickiness : 'default',
|
||||||
payload: payload.value ? payload : undefined,
|
payload: payload.value ? payload : undefined,
|
||||||
overrides: overrides
|
|
||||||
.map(o => ({
|
|
||||||
contextName: o.contextName,
|
|
||||||
values: o.values,
|
|
||||||
}))
|
|
||||||
.filter(o => o.values && o.values.length > 0),
|
|
||||||
isValid:
|
isValid:
|
||||||
isNameNotEmpty(name) &&
|
isNameNotEmpty(name) &&
|
||||||
isNameUnique(name, variant.id) &&
|
isNameUnique(name, variant.id) &&
|
||||||
isValidPercentage(percentage) &&
|
isValidPercentage(percentage) &&
|
||||||
isValidPayload(payload) &&
|
isValidPayload(payload) &&
|
||||||
!error,
|
!error,
|
||||||
});
|
};
|
||||||
|
if (!disableOverrides) {
|
||||||
|
newVariant['overrides'] = overrides
|
||||||
|
.map(o => ({
|
||||||
|
contextName: o.contextName,
|
||||||
|
values: o.values,
|
||||||
|
}))
|
||||||
|
.filter(o => o.values && o.values.length > 0);
|
||||||
|
}
|
||||||
|
updateVariant(newVariant);
|
||||||
}, [name, customPercentage, percentage, payload, overrides]);
|
}, [name, customPercentage, percentage, payload, overrides]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -423,24 +429,28 @@ export const VariantForm = ({
|
|||||||
/>
|
/>
|
||||||
</StyledFieldColumn>
|
</StyledFieldColumn>
|
||||||
</StyledRow>
|
</StyledRow>
|
||||||
<StyledMarginLabel>
|
{!disableOverrides ? (
|
||||||
Overrides
|
<>
|
||||||
<HelpIcon tooltip="Here you can specify which users should get this variant." />
|
<StyledMarginLabel>
|
||||||
</StyledMarginLabel>
|
Overrides
|
||||||
<OverrideConfig
|
<HelpIcon tooltip="Here you can specify which users should get this variant." />
|
||||||
overrides={overrides}
|
</StyledMarginLabel>
|
||||||
overridesDispatch={overridesDispatch}
|
<OverrideConfig
|
||||||
/>
|
overrides={overrides}
|
||||||
<div>
|
overridesDispatch={overridesDispatch}
|
||||||
<StyledAddOverrideButton
|
/>
|
||||||
onClick={onAddOverride}
|
<div>
|
||||||
variant="text"
|
<StyledAddOverrideButton
|
||||||
color="primary"
|
onClick={onAddOverride}
|
||||||
data-testid="VARIANT_ADD_OVERRIDE_BUTTON"
|
variant="text"
|
||||||
>
|
color="primary"
|
||||||
Add override
|
data-testid="VARIANT_ADD_OVERRIDE_BUTTON"
|
||||||
</StyledAddOverrideButton>
|
>
|
||||||
</div>
|
Add override
|
||||||
|
</StyledAddOverrideButton>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</StyledVariantForm>
|
</StyledVariantForm>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -0,0 +1,96 @@
|
|||||||
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
|
import { render } from 'utils/testRenderer';
|
||||||
|
import { StrategyVariants } from './StrategyVariants';
|
||||||
|
import { Route, Routes } from 'react-router-dom';
|
||||||
|
import { UPDATE_FEATURE_ENVIRONMENT_VARIANTS } from '../../providers/AccessProvider/permissions';
|
||||||
|
import { IFeatureStrategy } from '../../../interfaces/strategy';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
test('should render variants', async () => {
|
||||||
|
let currentStrategy: Partial<IFeatureStrategy> = {};
|
||||||
|
const initialStrategy = {
|
||||||
|
name: '',
|
||||||
|
constraints: [],
|
||||||
|
parameters: { stickiness: 'clientId' },
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
name: 'variantName',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 1000,
|
||||||
|
weightType: 'variable',
|
||||||
|
payload: {
|
||||||
|
type: 'string',
|
||||||
|
value: 'variantValue',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const Parent = () => {
|
||||||
|
const [strategy, setStrategy] =
|
||||||
|
useState<Partial<IFeatureStrategy>>(initialStrategy);
|
||||||
|
|
||||||
|
currentStrategy = strategy;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StrategyVariants strategy={strategy} setStrategy={setStrategy} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path={'projects/:projectId/features/:featureId/strategies/edit'}
|
||||||
|
element={<Parent />}
|
||||||
|
/>
|
||||||
|
</Routes>,
|
||||||
|
{
|
||||||
|
route: 'projects/default/features/colors/strategies/edit?environmentId=development&strategyId=2e4f0555-518b-45b3-b0cd-a32cca388a92',
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
permission: UPDATE_FEATURE_ENVIRONMENT_VARIANTS,
|
||||||
|
project: 'default',
|
||||||
|
environment: 'development',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// static form info
|
||||||
|
await screen.findByText('Variants');
|
||||||
|
const button = await screen.findByText('Add variant');
|
||||||
|
|
||||||
|
// variant form populated
|
||||||
|
const variantInput = screen.getByDisplayValue('variantValue');
|
||||||
|
expect(variantInput).toBeInTheDocument();
|
||||||
|
|
||||||
|
// overrides disabled
|
||||||
|
expect(screen.queryByText('Overrides')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// add second variant
|
||||||
|
button.click();
|
||||||
|
|
||||||
|
// UI allows to adjust percentages
|
||||||
|
const matchedElements = screen.getAllByText('Custom percentage');
|
||||||
|
expect(matchedElements.length).toBe(2);
|
||||||
|
|
||||||
|
// correct variants set on the parent
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(currentStrategy).toMatchObject({
|
||||||
|
...initialStrategy,
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
name: 'variantName',
|
||||||
|
payload: { type: 'string', value: 'variantValue' },
|
||||||
|
stickiness: 'clientId',
|
||||||
|
weight: 500,
|
||||||
|
weightType: 'variable',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '',
|
||||||
|
stickiness: 'clientId',
|
||||||
|
weight: 500,
|
||||||
|
weightType: 'variable',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -7,8 +7,7 @@ import { UPDATE_FEATURE_ENVIRONMENT_VARIANTS } from '../../providers/AccessProvi
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { WeightType } from '../../../constants/variantTypes';
|
import { WeightType } from '../../../constants/variantTypes';
|
||||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||||
import { useDefaultProjectSettings } from 'hooks/useDefaultProjectSettings';
|
import { styled, Typography } from '@mui/material';
|
||||||
import { styled } from '@mui/material';
|
|
||||||
import { useRequiredQueryParam } from 'hooks/useRequiredQueryParam';
|
import { useRequiredQueryParam } from 'hooks/useRequiredQueryParam';
|
||||||
import { IFeatureStrategy } from 'interfaces/strategy';
|
import { IFeatureStrategy } from 'interfaces/strategy';
|
||||||
|
|
||||||
@ -26,8 +25,10 @@ export const StrategyVariants: FC<{
|
|||||||
const projectId = useRequiredPathParam('projectId');
|
const projectId = useRequiredPathParam('projectId');
|
||||||
const environment = useRequiredQueryParam('environmentId');
|
const environment = useRequiredQueryParam('environmentId');
|
||||||
const [variantsEdit, setVariantsEdit] = useState<IFeatureVariantEdit[]>([]);
|
const [variantsEdit, setVariantsEdit] = useState<IFeatureVariantEdit[]>([]);
|
||||||
const [newVariant, setNewVariant] = useState<string>();
|
const stickiness =
|
||||||
const { defaultStickiness, loading } = useDefaultProjectSettings(projectId);
|
strategy?.parameters && 'stickiness' in strategy?.parameters
|
||||||
|
? String(strategy.parameters.stickiness)
|
||||||
|
: 'default';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVariantsEdit(
|
setVariantsEdit(
|
||||||
@ -41,19 +42,6 @@ export const StrategyVariants: FC<{
|
|||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setStrategy(prev => ({
|
|
||||||
...prev,
|
|
||||||
variants: variantsEdit.map(variant => ({
|
|
||||||
name: variant.name,
|
|
||||||
weight: variant.weight,
|
|
||||||
stickiness: variant.stickiness,
|
|
||||||
payload: variant.payload,
|
|
||||||
weightType: variant.weightType,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
}, [JSON.stringify(variantsEdit)]);
|
|
||||||
|
|
||||||
const updateVariant = (updatedVariant: IFeatureVariantEdit, id: string) => {
|
const updateVariant = (updatedVariant: IFeatureVariantEdit, id: string) => {
|
||||||
setVariantsEdit(prevVariants =>
|
setVariantsEdit(prevVariants =>
|
||||||
updateWeightEdit(
|
updateWeightEdit(
|
||||||
@ -63,6 +51,16 @@ export const StrategyVariants: FC<{
|
|||||||
1000
|
1000
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
setStrategy(prev => ({
|
||||||
|
...prev,
|
||||||
|
variants: variantsEdit.map(variant => ({
|
||||||
|
name: variant.name,
|
||||||
|
weight: variant.weight,
|
||||||
|
stickiness,
|
||||||
|
payload: variant.payload,
|
||||||
|
weightType: variant.weightType,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const addVariant = () => {
|
const addVariant = () => {
|
||||||
@ -73,24 +71,23 @@ export const StrategyVariants: FC<{
|
|||||||
name: '',
|
name: '',
|
||||||
weightType: WeightType.VARIABLE,
|
weightType: WeightType.VARIABLE,
|
||||||
weight: 0,
|
weight: 0,
|
||||||
overrides: [],
|
stickiness,
|
||||||
stickiness:
|
|
||||||
variantsEdit?.length > 0
|
|
||||||
? variantsEdit[0].stickiness
|
|
||||||
: defaultStickiness,
|
|
||||||
new: true,
|
new: true,
|
||||||
isValid: false,
|
isValid: false,
|
||||||
id,
|
id,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
setNewVariant(id);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Typography component="h3" sx={{ m: 0 }} variant="h3">
|
||||||
|
Variants
|
||||||
|
</Typography>
|
||||||
<StyledVariantForms>
|
<StyledVariantForms>
|
||||||
{variantsEdit.map(variant => (
|
{variantsEdit.map(variant => (
|
||||||
<VariantForm
|
<VariantForm
|
||||||
|
disableOverrides={true}
|
||||||
key={variant.id}
|
key={variant.id}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
variants={variantsEdit}
|
variants={variantsEdit}
|
||||||
|
@ -53,15 +53,7 @@ export interface IFeatureVariant {
|
|||||||
stickiness: string;
|
stickiness: string;
|
||||||
weight: number;
|
weight: number;
|
||||||
weightType: string;
|
weightType: string;
|
||||||
overrides: IOverride[];
|
overrides?: IOverride[];
|
||||||
payload?: IPayload;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IFeatureStrategyVariant {
|
|
||||||
name: string;
|
|
||||||
stickiness: string;
|
|
||||||
weight: number;
|
|
||||||
weightType: string;
|
|
||||||
payload?: IPayload;
|
payload?: IPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Operator } from 'constants/operators';
|
import { Operator } from 'constants/operators';
|
||||||
import { IFeatureStrategyVariant } from './featureToggle';
|
import { IFeatureVariant } from './featureToggle';
|
||||||
|
|
||||||
export interface IFeatureStrategy {
|
export interface IFeatureStrategy {
|
||||||
id: string;
|
id: string;
|
||||||
@ -8,7 +8,7 @@ export interface IFeatureStrategy {
|
|||||||
title?: string;
|
title?: string;
|
||||||
constraints: IConstraint[];
|
constraints: IConstraint[];
|
||||||
parameters: IFeatureStrategyParameters;
|
parameters: IFeatureStrategyParameters;
|
||||||
variants?: IFeatureStrategyVariant[];
|
variants?: IFeatureVariant[];
|
||||||
featureName?: string;
|
featureName?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
environment?: string;
|
environment?: string;
|
||||||
@ -26,7 +26,7 @@ export interface IFeatureStrategyPayload {
|
|||||||
title?: string;
|
title?: string;
|
||||||
constraints: IConstraint[];
|
constraints: IConstraint[];
|
||||||
parameters: IFeatureStrategyParameters;
|
parameters: IFeatureStrategyParameters;
|
||||||
variants?: IFeatureStrategyVariant[];
|
variants?: IFeatureVariant[];
|
||||||
segments?: number[];
|
segments?: number[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user