mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-15 01:16:22 +02:00
feat: Strategy variants stickiness (#4250)
This commit is contained in:
parent
fb6e4906a7
commit
56d5579b89
@ -248,7 +248,11 @@ export const FeatureStrategyForm = ({
|
||||
/>
|
||||
<StyledHr />
|
||||
<ConditionallyRender
|
||||
condition={Boolean(uiConfig?.flags?.strategyVariant)}
|
||||
condition={
|
||||
Boolean(uiConfig?.flags?.strategyVariant) &&
|
||||
strategy.parameters != null &&
|
||||
'stickiness' in strategy.parameters
|
||||
}
|
||||
show={
|
||||
<StrategyVariants
|
||||
strategy={strategy}
|
||||
|
@ -150,6 +150,7 @@ interface IVariantFormProps {
|
||||
updateVariant: (updatedVariant: IFeatureVariantEdit) => void;
|
||||
removeVariant: (variantId: string) => void;
|
||||
error?: string;
|
||||
disableOverrides?: boolean;
|
||||
}
|
||||
|
||||
export const VariantForm = ({
|
||||
@ -158,6 +159,7 @@ export const VariantForm = ({
|
||||
updateVariant,
|
||||
removeVariant,
|
||||
error,
|
||||
disableOverrides = false,
|
||||
}: IVariantFormProps) => {
|
||||
const [name, setName] = useState(variant.name);
|
||||
const [customPercentage, setCustomPercentage] = useState(
|
||||
@ -168,8 +170,9 @@ export const VariantForm = ({
|
||||
variant.payload || EMPTY_PAYLOAD
|
||||
);
|
||||
const [overrides, overridesDispatch] = useOverrides(
|
||||
variant.overrides || []
|
||||
'overrides' in variant ? variant.overrides || [] : []
|
||||
);
|
||||
|
||||
const { context } = useUnleashContext();
|
||||
|
||||
const [errors, setErrors] = useState<IVariantFormErrors>({});
|
||||
@ -269,7 +272,7 @@ export const VariantForm = ({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateVariant({
|
||||
const newVariant: IFeatureVariantEdit = {
|
||||
...variant,
|
||||
name,
|
||||
weight: Number(customPercentage ? percentage : 100) * 10,
|
||||
@ -277,19 +280,22 @@ export const VariantForm = ({
|
||||
stickiness:
|
||||
variants?.length > 0 ? variants[0].stickiness : 'default',
|
||||
payload: payload.value ? payload : undefined,
|
||||
overrides: overrides
|
||||
.map(o => ({
|
||||
contextName: o.contextName,
|
||||
values: o.values,
|
||||
}))
|
||||
.filter(o => o.values && o.values.length > 0),
|
||||
isValid:
|
||||
isNameNotEmpty(name) &&
|
||||
isNameUnique(name, variant.id) &&
|
||||
isValidPercentage(percentage) &&
|
||||
isValidPayload(payload) &&
|
||||
!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]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -423,24 +429,28 @@ export const VariantForm = ({
|
||||
/>
|
||||
</StyledFieldColumn>
|
||||
</StyledRow>
|
||||
<StyledMarginLabel>
|
||||
Overrides
|
||||
<HelpIcon tooltip="Here you can specify which users should get this variant." />
|
||||
</StyledMarginLabel>
|
||||
<OverrideConfig
|
||||
overrides={overrides}
|
||||
overridesDispatch={overridesDispatch}
|
||||
/>
|
||||
<div>
|
||||
<StyledAddOverrideButton
|
||||
onClick={onAddOverride}
|
||||
variant="text"
|
||||
color="primary"
|
||||
data-testid="VARIANT_ADD_OVERRIDE_BUTTON"
|
||||
>
|
||||
Add override
|
||||
</StyledAddOverrideButton>
|
||||
</div>
|
||||
{!disableOverrides ? (
|
||||
<>
|
||||
<StyledMarginLabel>
|
||||
Overrides
|
||||
<HelpIcon tooltip="Here you can specify which users should get this variant." />
|
||||
</StyledMarginLabel>
|
||||
<OverrideConfig
|
||||
overrides={overrides}
|
||||
overridesDispatch={overridesDispatch}
|
||||
/>
|
||||
<div>
|
||||
<StyledAddOverrideButton
|
||||
onClick={onAddOverride}
|
||||
variant="text"
|
||||
color="primary"
|
||||
data-testid="VARIANT_ADD_OVERRIDE_BUTTON"
|
||||
>
|
||||
Add override
|
||||
</StyledAddOverrideButton>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</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 { WeightType } from '../../../constants/variantTypes';
|
||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||
import { useDefaultProjectSettings } from 'hooks/useDefaultProjectSettings';
|
||||
import { styled } from '@mui/material';
|
||||
import { styled, Typography } from '@mui/material';
|
||||
import { useRequiredQueryParam } from 'hooks/useRequiredQueryParam';
|
||||
import { IFeatureStrategy } from 'interfaces/strategy';
|
||||
|
||||
@ -26,8 +25,10 @@ export const StrategyVariants: FC<{
|
||||
const projectId = useRequiredPathParam('projectId');
|
||||
const environment = useRequiredQueryParam('environmentId');
|
||||
const [variantsEdit, setVariantsEdit] = useState<IFeatureVariantEdit[]>([]);
|
||||
const [newVariant, setNewVariant] = useState<string>();
|
||||
const { defaultStickiness, loading } = useDefaultProjectSettings(projectId);
|
||||
const stickiness =
|
||||
strategy?.parameters && 'stickiness' in strategy?.parameters
|
||||
? String(strategy.parameters.stickiness)
|
||||
: 'default';
|
||||
|
||||
useEffect(() => {
|
||||
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) => {
|
||||
setVariantsEdit(prevVariants =>
|
||||
updateWeightEdit(
|
||||
@ -63,6 +51,16 @@ export const StrategyVariants: FC<{
|
||||
1000
|
||||
)
|
||||
);
|
||||
setStrategy(prev => ({
|
||||
...prev,
|
||||
variants: variantsEdit.map(variant => ({
|
||||
name: variant.name,
|
||||
weight: variant.weight,
|
||||
stickiness,
|
||||
payload: variant.payload,
|
||||
weightType: variant.weightType,
|
||||
})),
|
||||
}));
|
||||
};
|
||||
|
||||
const addVariant = () => {
|
||||
@ -73,24 +71,23 @@ export const StrategyVariants: FC<{
|
||||
name: '',
|
||||
weightType: WeightType.VARIABLE,
|
||||
weight: 0,
|
||||
overrides: [],
|
||||
stickiness:
|
||||
variantsEdit?.length > 0
|
||||
? variantsEdit[0].stickiness
|
||||
: defaultStickiness,
|
||||
stickiness,
|
||||
new: true,
|
||||
isValid: false,
|
||||
id,
|
||||
},
|
||||
]);
|
||||
setNewVariant(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography component="h3" sx={{ m: 0 }} variant="h3">
|
||||
Variants
|
||||
</Typography>
|
||||
<StyledVariantForms>
|
||||
{variantsEdit.map(variant => (
|
||||
<VariantForm
|
||||
disableOverrides={true}
|
||||
key={variant.id}
|
||||
variant={variant}
|
||||
variants={variantsEdit}
|
||||
|
@ -53,15 +53,7 @@ export interface IFeatureVariant {
|
||||
stickiness: string;
|
||||
weight: number;
|
||||
weightType: string;
|
||||
overrides: IOverride[];
|
||||
payload?: IPayload;
|
||||
}
|
||||
|
||||
export interface IFeatureStrategyVariant {
|
||||
name: string;
|
||||
stickiness: string;
|
||||
weight: number;
|
||||
weightType: string;
|
||||
overrides?: IOverride[];
|
||||
payload?: IPayload;
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Operator } from 'constants/operators';
|
||||
import { IFeatureStrategyVariant } from './featureToggle';
|
||||
import { IFeatureVariant } from './featureToggle';
|
||||
|
||||
export interface IFeatureStrategy {
|
||||
id: string;
|
||||
@ -8,7 +8,7 @@ export interface IFeatureStrategy {
|
||||
title?: string;
|
||||
constraints: IConstraint[];
|
||||
parameters: IFeatureStrategyParameters;
|
||||
variants?: IFeatureStrategyVariant[];
|
||||
variants?: IFeatureVariant[];
|
||||
featureName?: string;
|
||||
projectId?: string;
|
||||
environment?: string;
|
||||
@ -26,7 +26,7 @@ export interface IFeatureStrategyPayload {
|
||||
title?: string;
|
||||
constraints: IConstraint[];
|
||||
parameters: IFeatureStrategyParameters;
|
||||
variants?: IFeatureStrategyVariant[];
|
||||
variants?: IFeatureVariant[];
|
||||
segments?: number[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user