1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-23 00:22:19 +01:00

Variants per environment (frontend) (#2453)

![image](https://user-images.githubusercontent.com/14320932/202286759-b9c30228-59cc-4c58-a7b0-3c6c3d0ecba6.png)
## About the changes

https://linear.app/unleash/issue/2-425/variants-crud-new-environment-cards-with-tables-inside-add-edit-and

Basically created parallel components for the **variants per
environments** feature, so both flows should work correctly depending on
the feature flag state. Some of the duplication means that cleanup
should be straightforward - Once we're happy with this feature it should
be enough to delete
`frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList`
and do some little extra cleanup.

I noticed we had some legacy-looking code in variants, so this involved
*some* rewriting of the current variants logic. Hopefully this new code
looks nicer, more maintainable, and more importantly **doesn't break
anything**.

Relates to [roadmap](https://github.com/orgs/Unleash/projects/10) item:
#2254

### Important files
Everything inside the
`frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants`
folder.
This commit is contained in:
Nuno Góis 2022-11-18 11:43:24 +00:00 committed by GitHub
parent 31dc31fdf4
commit 93bd9d869a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1870 additions and 12 deletions

View File

@ -33,7 +33,6 @@ export const DateTimePicker = ({
max,
value,
onChange,
InputProps,
...rest
}: IDateTimePickerProps) => {
const getDate = type === 'datetime' ? formatDateTime : formatDate;

View File

@ -3,7 +3,7 @@ import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
helperText: {
position: 'absolute',
top: '35px',
bottom: '-1rem',
},
inputContainer: {
width: '100%',

View File

@ -1,6 +1,5 @@
import { INPUT_ERROR_TEXT } from 'utils/testIds';
import { useStyles } from './Input.styles';
import React from 'react';
import { TextField, OutlinedTextFieldProps } from '@mui/material';
interface IInputProps extends Omit<OutlinedTextFieldProps, 'variant'> {
@ -26,7 +25,6 @@ const Input = ({
className,
value,
onChange,
InputProps,
...rest
}: IInputProps) => {
const { classes: styles } = useStyles();

View File

@ -94,12 +94,12 @@ enum ErrorField {
PROJECTS = 'projects',
}
interface ICreatePersonalAPITokenErrors {
interface IEnvironmentCloneModalErrors {
[ErrorField.NAME]?: string;
[ErrorField.PROJECTS]?: string;
}
interface ICreatePersonalAPITokenProps {
interface IEnvironmentCloneModalProps {
environment: IEnvironment;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
@ -111,7 +111,7 @@ export const EnvironmentCloneModal = ({
open,
setOpen,
newToken,
}: ICreatePersonalAPITokenProps) => {
}: IEnvironmentCloneModalProps) => {
const { environments, refetchEnvironments } = useEnvironments();
const { cloneEnvironment, loading } = useEnvironmentApi();
const { createToken } = useApiTokensApi();
@ -126,7 +126,7 @@ export const EnvironmentCloneModal = ({
const [clonePermissions, setClonePermissions] = useState(true);
const [apiTokenGeneration, setApiTokenGeneration] =
useState<APITokenGeneration>(APITokenGeneration.LATER);
const [errors, setErrors] = useState<ICreatePersonalAPITokenErrors>({});
const [errors, setErrors] = useState<IEnvironmentCloneModalErrors>({});
const clearError = (field: ErrorField) => {
setErrors(errors => ({ ...errors, [field]: undefined }));

View File

@ -0,0 +1,506 @@
import {
Alert,
Button,
FormControlLabel,
InputAdornment,
styled,
} from '@mui/material';
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { FormEvent, useEffect, useState } from 'react';
import Input from 'component/common/Input/Input';
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import {
IFeatureEnvironment,
IFeatureVariant,
IPayload,
} from 'interfaces/featureToggle';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { Operation } from 'fast-json-patch';
import { useOverrides } from 'component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/useOverrides';
import SelectMenu from 'component/common/select';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
import { OverrideConfig } from 'component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/VariantOverrides';
import cloneDeep from 'lodash.clonedeep';
import { CloudCircle } from '@mui/icons-material';
import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch';
import { UPDATE_FEATURE_VARIANTS } from 'component/providers/AccessProvider/permissions';
const StyledFormSubtitle = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
marginTop: theme.spacing(-1.5),
marginBottom: theme.spacing(4),
}));
const StyledCloudCircle = styled(CloudCircle, {
shouldForwardProp: prop => prop !== 'deprecated',
})<{ deprecated?: boolean }>(({ theme, deprecated }) => ({
color: deprecated
? theme.palette.neutral.border
: theme.palette.primary.main,
}));
const StyledName = styled('span', {
shouldForwardProp: prop => prop !== 'deprecated',
})<{ deprecated?: boolean }>(({ theme, deprecated }) => ({
color: deprecated
? theme.palette.text.secondary
: theme.palette.text.primary,
marginLeft: theme.spacing(1.25),
}));
const StyledForm = styled('form')(() => ({
display: 'flex',
flexDirection: 'column',
height: '100%',
}));
const StyledInputDescription = styled('p')(({ theme }) => ({
display: 'flex',
color: theme.palette.text.primary,
marginBottom: theme.spacing(1),
'&:not(:first-of-type)': {
marginTop: theme.spacing(4),
},
}));
const StyledFormControlLabel = styled(FormControlLabel)(({ theme }) => ({
marginTop: theme.spacing(4),
marginBottom: theme.spacing(1.5),
}));
const StyledInputSecondaryDescription = styled('p')(({ theme }) => ({
color: theme.palette.text.secondary,
marginBottom: theme.spacing(1),
}));
const StyledInput = styled(Input)(() => ({
width: '100%',
}));
const StyledRow = styled('div')(({ theme }) => ({
display: 'flex',
rowGap: theme.spacing(1.5),
marginBottom: theme.spacing(2),
[theme.breakpoints.down('sm')]: {
flexDirection: 'column',
'& > div, .MuiInputBase-root': {
width: '100%',
},
},
}));
const StyledSelectMenu = styled(SelectMenu)(({ theme }) => ({
minWidth: theme.spacing(20),
marginRight: theme.spacing(10),
}));
const StyledAlert = styled(Alert)(({ theme }) => ({
marginTop: theme.spacing(4),
}));
const StyledButtonContainer = styled('div')(({ theme }) => ({
marginTop: 'auto',
display: 'flex',
justifyContent: 'flex-end',
[theme.breakpoints.down('sm')]: {
marginTop: theme.spacing(4),
},
}));
const StyledCancelButton = styled(Button)(({ theme }) => ({
marginLeft: theme.spacing(3),
}));
const payloadOptions = [
{ key: 'string', label: 'string' },
{ key: 'json', label: 'json' },
{ key: 'csv', label: 'csv' },
];
enum WeightType {
FIX = 'fix',
VARIABLE = 'variable',
}
const EMPTY_PAYLOAD = { type: 'string', value: '' };
enum ErrorField {
NAME = 'name',
PERCENTAGE = 'percentage',
PAYLOAD = 'payload',
OTHER = 'other',
}
interface IEnvironmentVariantModalErrors {
[ErrorField.NAME]?: string;
[ErrorField.PERCENTAGE]?: string;
[ErrorField.PAYLOAD]?: string;
[ErrorField.OTHER]?: string;
}
interface IEnvironmentVariantModalProps {
environment?: IFeatureEnvironment;
variant?: IFeatureVariant;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
getApiPayload: (
variants: IFeatureVariant[],
newVariants: IFeatureVariant[]
) => { patch: Operation[]; error?: string };
onConfirm: (updatedVariants: IFeatureVariant[]) => void;
}
export const EnvironmentVariantModal = ({
environment,
variant,
open,
setOpen,
getApiPayload,
onConfirm,
}: IEnvironmentVariantModalProps) => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { uiConfig } = useUiConfig();
const [name, setName] = useState('');
const [customPercentage, setCustomPercentage] = useState(false);
const [percentage, setPercentage] = useState('');
const [payload, setPayload] = useState<IPayload>(EMPTY_PAYLOAD);
const [overrides, overridesDispatch] = useOverrides([]);
const { context } = useUnleashContext();
const [errors, setErrors] = useState<IEnvironmentVariantModalErrors>({});
const clearError = (field: ErrorField) => {
setErrors(errors => ({ ...errors, [field]: undefined }));
};
const setError = (field: ErrorField, error: string) => {
setErrors(errors => ({ ...errors, [field]: error }));
};
const editing = Boolean(variant);
const variants = environment?.variants || [];
const customPercentageVisible =
(editing && variants.length > 1) || (!editing && variants.length > 0);
useEffect(() => {
if (variant) {
setName(variant.name);
setCustomPercentage(variant.weightType === WeightType.FIX);
setPercentage(String(variant.weight / 10));
setPayload(variant.payload || EMPTY_PAYLOAD);
overridesDispatch(
variant.overrides
? { type: 'SET', payload: variant.overrides || [] }
: { type: 'CLEAR' }
);
} else {
setName('');
setCustomPercentage(false);
setPercentage('');
setPayload(EMPTY_PAYLOAD);
overridesDispatch({ type: 'CLEAR' });
}
setErrors({});
}, [open, variant]);
const getUpdatedVariants = (): IFeatureVariant[] => {
const newVariant: IFeatureVariant = {
name,
weight: Number(customPercentage ? percentage : 100) * 10,
weightType: customPercentage ? WeightType.FIX : WeightType.VARIABLE,
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),
};
const updatedVariants = cloneDeep(variants);
if (editing) {
const variantIdxToUpdate = updatedVariants.findIndex(
(variant: IFeatureVariant) => variant.name === newVariant.name
);
updatedVariants[variantIdxToUpdate] = newVariant;
} else {
updatedVariants.push(newVariant);
}
return updatedVariants;
};
const apiPayload = getApiPayload(variants, getUpdatedVariants());
useEffect(() => {
clearError(ErrorField.PERCENTAGE);
clearError(ErrorField.OTHER);
if (apiPayload.error) {
if (apiPayload.error.includes('%')) {
setError(ErrorField.PERCENTAGE, apiPayload.error);
} else {
setError(ErrorField.OTHER, apiPayload.error);
}
}
}, [apiPayload.error]);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
onConfirm(getUpdatedVariants());
};
const formatApiCode = () => `curl --location --request PATCH '${
uiConfig.unleashUrl
}/api/admin/projects/${projectId}/features/${featureId}/environments/${
environment?.name
}/variants' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${JSON.stringify(apiPayload.patch, undefined, 2)}'`;
const isNameNotEmpty = (name: string) => name.length;
const isNameUnique = (name: string) =>
editing || !variants.some(variant => variant.name === name);
const isValidPercentage = (percentage: string) => {
if (!customPercentage) return true;
if (percentage === '') return false;
const percentageNumber = Number(percentage);
return percentageNumber >= 0 && percentageNumber <= 100;
};
const isValidPayload = (payload: IPayload): boolean => {
try {
if (payload.type === 'json') {
JSON.parse(payload.value);
}
return true;
} catch (e: unknown) {
return false;
}
};
const isValid =
isNameNotEmpty(name) &&
isNameUnique(name) &&
isValidPercentage(percentage) &&
isValidPayload(payload) &&
!apiPayload.error;
const onSetName = (name: string) => {
clearError(ErrorField.NAME);
if (!isNameUnique(name)) {
setError(
ErrorField.NAME,
'A variant with that name already exists for this environment.'
);
}
setName(name);
};
const onSetPercentage = (percentage: string) => {
if (percentage === '' || isValidPercentage(percentage)) {
setPercentage(percentage);
}
};
const validatePayload = (payload: IPayload) => {
if (!isValidPayload(payload)) {
setError(ErrorField.PAYLOAD, 'Invalid JSON.');
}
};
const onAddOverride = () => {
if (context.length > 0) {
overridesDispatch({
type: 'ADD',
payload: { contextName: context[0].name, values: [] },
});
}
};
return (
<SidebarModal
open={open}
onClose={() => {
setOpen(false);
}}
label={editing ? 'Edit variant' : 'Add variant'}
>
<FormTemplate
modal
title={editing ? 'Edit variant' : 'Add variant'}
description="Variants allows you to return a variant object if the feature toggle is considered enabled for the current request."
documentationLink="https://docs.getunleash.io/advanced/toggle_variants"
documentationLinkLabel="Feature toggle variants documentation"
formatApiCode={formatApiCode}
loading={!open}
>
<StyledFormSubtitle>
<StyledCloudCircle deprecated={!environment?.enabled} />
<StyledName deprecated={!environment?.enabled}>
{environment?.name}
</StyledName>
</StyledFormSubtitle>
<StyledForm onSubmit={handleSubmit}>
<div>
<StyledInputDescription>
Variant name
</StyledInputDescription>
<StyledInputSecondaryDescription>
This will be used to identify the variant in your
code
</StyledInputSecondaryDescription>
<StyledInput
autoFocus
label="Variant name"
error={Boolean(errors.name)}
errorText={errors.name}
value={name}
onChange={e => onSetName(e.target.value)}
disabled={editing}
required
/>
<ConditionallyRender
condition={customPercentageVisible}
show={
<StyledFormControlLabel
label="Custom percentage"
control={
<PermissionSwitch
permission={UPDATE_FEATURE_VARIANTS}
projectId={projectId}
checked={customPercentage}
onChange={e =>
setCustomPercentage(
e.target.checked
)
}
/>
}
/>
}
/>
<ConditionallyRender
condition={customPercentage}
show={
<StyledInput
type="number"
label="Variant weight"
error={Boolean(errors.percentage)}
errorText={errors.percentage}
value={percentage}
onChange={e =>
onSetPercentage(e.target.value)
}
required={customPercentage}
disabled={!customPercentage}
aria-valuemin={0}
aria-valuemax={100}
InputProps={{
endAdornment: (
<InputAdornment position="end">
%
</InputAdornment>
),
}}
/>
}
/>
<StyledInputDescription>
Payload
<HelpIcon tooltip="Passed along with the the variant object." />
</StyledInputDescription>
<StyledRow>
<StyledSelectMenu
id="variant-payload-type"
name="type"
label="Type"
value={payload.type}
options={payloadOptions}
onChange={e => {
clearError(ErrorField.PAYLOAD);
setPayload(payload => ({
...payload,
type: e.target.value,
}));
}}
/>
<StyledInput
id="variant-payload-value"
name="variant-payload-value"
label="Value"
multiline={payload.type !== 'string'}
rows={payload.type === 'string' ? 1 : 4}
value={payload.value}
onChange={e => {
clearError(ErrorField.PAYLOAD);
setPayload(payload => ({
...payload,
value: e.target.value,
}));
}}
placeholder={
payload.type === 'json'
? '{ "hello": "world" }'
: ''
}
onBlur={() => validatePayload(payload)}
error={Boolean(errors.payload)}
errorText={errors.payload}
/>
</StyledRow>
<StyledInputDescription>
Overrides
<HelpIcon tooltip="Here you can specify which users should get this variant." />
</StyledInputDescription>
<OverrideConfig
overrides={overrides}
overridesDispatch={overridesDispatch}
/>
<Button
onClick={onAddOverride}
variant="outlined"
color="primary"
>
Add override
</Button>
</div>
<StyledAlert
severity="error"
hidden={!Boolean(errors.other)}
>
<strong>Error: </strong>
{errors.other}
</StyledAlert>
<StyledButtonContainer>
<Button
type="submit"
variant="contained"
color="primary"
disabled={!isValid}
>
{editing ? 'Save' : 'Add'} variant
</Button>
<StyledCancelButton
onClick={() => {
setOpen(false);
}}
>
Cancel
</StyledCancelButton>
</StyledButtonContainer>
</StyledForm>
</FormTemplate>
</SidebarModal>
);
};

View File

@ -0,0 +1,163 @@
import { ChangeEvent, VFC } from 'react';
import { IconButton, styled, TextField, Tooltip } from '@mui/material';
import { Delete } from '@mui/icons-material';
import { Autocomplete } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { InputListField } from 'component/common/InputListField/InputListField';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
import { IOverride } from 'interfaces/featureToggle';
import { OverridesDispatchType } from './useOverrides';
import SelectMenu from 'component/common/select';
const StyledRow = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
rowGap: theme.spacing(1.5),
marginBottom: theme.spacing(2),
[theme.breakpoints.down('sm')]: {
flexDirection: 'column',
'& > div, .MuiInputBase-root': {
width: '100%',
alignItems: 'flex-start',
},
},
}));
const StyledSelectMenu = styled(SelectMenu)(({ theme }) => ({
minWidth: theme.spacing(20),
marginRight: theme.spacing(10),
}));
const StyledFieldColumn = styled('div')(({ theme }) => ({
width: '100%',
gap: theme.spacing(1.5),
display: 'flex',
}));
const StyledInputListField = styled(InputListField)(() => ({
width: '100%',
}));
const StyledTextField = styled(TextField)(() => ({
width: '100%',
}));
interface IOverrideConfigProps {
overrides: IOverride[];
overridesDispatch: OverridesDispatchType;
}
export const OverrideConfig: VFC<IOverrideConfigProps> = ({
overrides,
overridesDispatch,
}) => {
const { context } = useUnleashContext();
const contextNames = context.map(({ name }) => ({
key: name,
label: name,
}));
const updateValues = (index: number) => (values: string[]) => {
overridesDispatch({
type: 'UPDATE_VALUES_AT',
payload: [index, values],
});
};
const updateSelectValues =
(index: number) => (event: ChangeEvent<unknown>, options: string[]) => {
event?.preventDefault();
overridesDispatch({
type: 'UPDATE_VALUES_AT',
payload: [index, options ? options : []],
});
};
return (
<>
{overrides.map((override, index) => {
const definition = context.find(
({ name }) => name === override.contextName
);
const legalValues =
definition?.legalValues?.map(({ value }) => value) || [];
const filteredValues = override.values.filter(value =>
legalValues.includes(value)
);
return (
<StyledRow key={`override=${index}`}>
<StyledSelectMenu
id="override-context-name"
name="contextName"
label="Context Field"
value={override.contextName}
options={contextNames}
onChange={e =>
overridesDispatch({
type: 'UPDATE_TYPE_AT',
payload: [index, e.target.value],
})
}
/>
<StyledFieldColumn>
<ConditionallyRender
condition={Boolean(
legalValues && legalValues.length > 0
)}
show={
<Autocomplete
multiple
id={`override-select-${index}`}
isOptionEqualToValue={(
option,
value
) => {
return option === value;
}}
options={legalValues}
onChange={updateSelectValues(index)}
getOptionLabel={option => option}
value={filteredValues}
style={{ width: '100%' }}
filterSelectedOptions
size="small"
renderInput={params => (
<StyledTextField
{...params}
variant="outlined"
label="Legal values"
/>
)}
/>
}
elseShow={
<StyledInputListField
label="Values (v1, v2, ...)"
name="values"
placeholder=""
values={override.values}
updateValues={updateValues(index)}
/>
}
/>
<Tooltip title="Remove" arrow>
<IconButton
onClick={event => {
event.preventDefault();
overridesDispatch({
type: 'REMOVE',
payload: index,
});
}}
>
<Delete />
</IconButton>
</Tooltip>
</StyledFieldColumn>
</StyledRow>
);
})}
</>
);
};

View File

@ -0,0 +1,154 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { useOverrides } from './useOverrides';
describe('useOverrides', () => {
it('should return initial value', () => {
const { result } = renderHook(() =>
useOverrides([{ contextName: 'context', values: ['a', 'b'] }])
);
expect(result.current[0]).toEqual([
{ contextName: 'context', values: ['a', 'b'] },
]);
});
it('should set value with an action', () => {
const { result } = renderHook(() =>
useOverrides([
{ contextName: 'X', values: ['a', 'b'] },
{ contextName: 'Y', values: ['a', 'b', 'c'] },
])
);
const [, dispatch] = result.current;
act(() => {
dispatch({
type: 'SET',
payload: [{ contextName: 'Z', values: ['d'] }],
});
});
expect(result.current[0]).toEqual([
{ contextName: 'Z', values: ['d'] },
]);
});
it('should clear all overrides with an action', () => {
const { result } = renderHook(() =>
useOverrides([
{ contextName: 'X', values: ['a', 'b'] },
{ contextName: 'Y', values: ['a', 'b', 'c'] },
])
);
const [, dispatch] = result.current;
act(() => {
dispatch({
type: 'CLEAR',
});
});
expect(result.current[0]).toEqual([]);
});
it('should add value with an action', () => {
const { result } = renderHook(() =>
useOverrides([
{ contextName: 'X', values: ['a'] },
{ contextName: 'Y', values: ['b'] },
])
);
const [, dispatch] = result.current;
act(() => {
dispatch({
type: 'ADD',
payload: { contextName: 'Z', values: ['c'] },
});
});
expect(result.current[0]).toEqual([
{ contextName: 'X', values: ['a'] },
{ contextName: 'Y', values: ['b'] },
{ contextName: 'Z', values: ['c'] },
]);
});
it('should remove override at index with an action', () => {
const { result } = renderHook(() =>
useOverrides([
{ contextName: '1', values: [] },
{ contextName: '2', values: ['a'] },
{ contextName: '3', values: ['b'] },
{ contextName: '4', values: ['c'] },
])
);
const [, dispatch] = result.current;
act(() => {
dispatch({ type: 'REMOVE', payload: 1 });
});
expect(result.current[0]).toEqual([
{ contextName: '1', values: [] },
{ contextName: '3', values: ['b'] },
{ contextName: '4', values: ['c'] },
]);
act(() => {
dispatch({ type: 'REMOVE', payload: 2 });
});
expect(result.current[0]).toEqual([
{ contextName: '1', values: [] },
{ contextName: '3', values: ['b'] },
]);
});
it('should update at index with an action', () => {
const { result } = renderHook(() =>
useOverrides([
{ contextName: '1', values: [] },
{ contextName: '2', values: ['a'] },
{ contextName: '3', values: ['b'] },
])
);
const [, dispatch] = result.current;
act(() => {
dispatch({
type: 'UPDATE_VALUES_AT',
payload: [1, ['x', 'y', 'z']],
});
});
expect(result.current[0]).toEqual([
{ contextName: '1', values: [] },
{ contextName: '2', values: ['x', 'y', 'z'] },
{ contextName: '3', values: ['b'] },
]);
});
it('should change context at index with an action', () => {
const { result } = renderHook(() =>
useOverrides([
{ contextName: '1', values: ['x'] },
{ contextName: '2', values: ['y'] },
{ contextName: '3', values: ['z'] },
])
);
const [, dispatch] = result.current;
act(() => {
dispatch({
type: 'UPDATE_TYPE_AT',
payload: [1, 'NewContext'],
});
});
expect(result.current[0]).toEqual([
{ contextName: '1', values: ['x'] },
{ contextName: 'NewContext', values: ['y'] },
{ contextName: '3', values: ['z'] },
]);
});
});

View File

@ -0,0 +1,41 @@
import { useReducer } from 'react';
import { IOverride } from 'interfaces/featureToggle';
type OverridesReducerAction =
| { type: 'SET'; payload: IOverride[] }
| { type: 'CLEAR' }
| { type: 'ADD'; payload: IOverride }
| { type: 'REMOVE'; payload: number }
| { type: 'UPDATE_VALUES_AT'; payload: [number, IOverride['values']] }
| { type: 'UPDATE_TYPE_AT'; payload: [number, IOverride['contextName']] };
const overridesReducer = (
state: IOverride[],
action: OverridesReducerAction
) => {
switch (action.type) {
case 'SET':
return action.payload;
case 'CLEAR':
return [];
case 'ADD':
return [...state, action.payload];
case 'REMOVE':
return state.filter((_, index) => index !== action.payload);
case 'UPDATE_VALUES_AT':
const [index1, values] = action.payload;
return state.map((item, index) =>
index === index1 ? { ...item, values } : item
);
case 'UPDATE_TYPE_AT':
const [index2, contextName] = action.payload;
return state.map((item, index) =>
index === index2 ? { ...item, contextName } : item
);
}
};
export const useOverrides = (initialValue: IOverride[] = []) =>
useReducer(overridesReducer, initialValue);
export type OverridesDispatchType = ReturnType<typeof useOverrides>[1];

View File

@ -0,0 +1,169 @@
import { Add, CloudCircle } from '@mui/icons-material';
import { Button, Divider, styled } from '@mui/material';
import { IFeatureEnvironment, IFeatureVariant } from 'interfaces/featureToggle';
import { EnvironmentVariantsTable } from './EnvironmentVariantsTable/EnvironmentVariantsTable';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
import { useMemo } from 'react';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
const StyledCard = styled('div')(({ theme }) => ({
padding: theme.spacing(3),
borderRadius: theme.shape.borderRadiusLarge,
border: `1px solid ${theme.palette.dividerAlternative}`,
'&:not(:last-child)': {
marginBottom: theme.spacing(3),
},
}));
const StyledHeader = styled('div')(() => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
'& > div': {
display: 'flex',
alignItems: 'center',
},
}));
const StyledCloudCircle = styled(CloudCircle, {
shouldForwardProp: prop => prop !== 'deprecated',
})<{ deprecated?: boolean }>(({ theme, deprecated }) => ({
color: deprecated
? theme.palette.neutral.border
: theme.palette.primary.main,
}));
const StyledName = styled('span', {
shouldForwardProp: prop => prop !== 'deprecated',
})<{ deprecated?: boolean }>(({ theme, deprecated }) => ({
color: deprecated
? theme.palette.text.secondary
: theme.palette.text.primary,
marginLeft: theme.spacing(1.25),
}));
const StyledDivider = styled(Divider)(({ theme }) => ({
margin: theme.spacing(3, 0),
}));
const StyledDescription = styled('p')(({ theme }) => ({
fontSize: theme.fontSizes.smallBody,
color: theme.palette.text.secondary,
marginBottom: theme.spacing(1.5),
}));
const StyledGeneralSelect = styled(GeneralSelect)(({ theme }) => ({
minWidth: theme.spacing(20),
}));
interface IEnvironmentVariantsCardProps {
environment: IFeatureEnvironment;
searchValue: string;
onAddVariant: () => void;
onEditVariant: (variant: IFeatureVariant) => void;
onDeleteVariant: (variant: IFeatureVariant) => void;
onUpdateStickiness: (variant: IFeatureVariant[]) => void;
children?: React.ReactNode;
}
export const EnvironmentVariantsCard = ({
environment,
searchValue,
onAddVariant,
onEditVariant,
onDeleteVariant,
onUpdateStickiness,
children,
}: IEnvironmentVariantsCardProps) => {
const { context } = useUnleashContext();
const variants = environment.variants ?? [];
const stickiness = variants[0]?.stickiness || 'default';
const stickinessOptions = useMemo(
() => [
'default',
...context.filter(c => c.stickiness).map(c => c.name),
],
[context]
);
const options = stickinessOptions.map(c => ({ key: c, label: c }));
if (!stickinessOptions.includes(stickiness)) {
options.push({ key: stickiness, label: stickiness });
}
const updateStickiness = async (stickiness: string) => {
const newVariants = [...variants].map(variant => ({
...variant,
stickiness,
}));
onUpdateStickiness(newVariants);
};
const onStickinessChange = (value: string) => {
updateStickiness(value).catch(console.warn);
};
return (
<StyledCard>
<StyledHeader>
<div>
<StyledCloudCircle deprecated={!environment.enabled} />
<StyledName deprecated={!environment.enabled}>
{environment.name}
</StyledName>
</div>
{children}
</StyledHeader>
<ConditionallyRender
condition={variants.length > 0}
show={
<>
<EnvironmentVariantsTable
environment={environment}
searchValue={searchValue}
onEditVariant={onEditVariant}
onDeleteVariant={onDeleteVariant}
/>
<Button
onClick={onAddVariant}
variant="text"
startIcon={<Add />}
>
add variant
</Button>
<ConditionallyRender
condition={variants.length > 1}
show={
<>
<StyledDivider />
<p>Stickiness</p>
<StyledDescription>
By overriding the stickiness you can
control which parameter is used to
ensure consistent traffic allocation
across variants.{' '}
<a
href="https://docs.getunleash.io/advanced/toggle_variants"
target="_blank"
rel="noreferrer"
>
Read more
</a>
</StyledDescription>
<StyledGeneralSelect
options={options}
value={stickiness}
onChange={onStickinessChange}
/>
</>
}
/>
</>
}
/>
</StyledCard>
);
};

View File

@ -0,0 +1,184 @@
import { styled, useMediaQuery, useTheme } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { calculateVariantWeight } from 'component/common/util';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useSearch } from 'hooks/useSearch';
import {
IFeatureEnvironment,
IFeatureVariant,
IOverride,
IPayload,
} from 'interfaces/featureToggle';
import { useEffect, useMemo } from 'react';
import { useFlexLayout, useSortBy, useTable } from 'react-table';
import { sortTypes } from 'utils/sortTypes';
import { PayloadCell } from './PayloadCell/PayloadCell';
import { OverridesCell } from './OverridesCell/OverridesCell';
import { VariantsActionCell } from './VariantsActionsCell/VariantsActionsCell';
const StyledTableContainer = styled('div')(({ theme }) => ({
margin: theme.spacing(3, 0),
}));
interface IEnvironmentVariantsTableProps {
environment: IFeatureEnvironment;
searchValue: string;
onEditVariant: (variant: IFeatureVariant) => void;
onDeleteVariant: (variant: IFeatureVariant) => void;
}
export const EnvironmentVariantsTable = ({
environment,
searchValue,
onEditVariant,
onDeleteVariant,
}: IEnvironmentVariantsTableProps) => {
const projectId = useRequiredPathParam('projectId');
const theme = useTheme();
const isMediumScreen = useMediaQuery(theme.breakpoints.down('md'));
const isLargeScreen = useMediaQuery(theme.breakpoints.down('lg'));
const variants = environment.variants ?? [];
const columns = useMemo(
() => [
{
Header: 'Name',
accessor: 'name',
Cell: HighlightCell,
sortType: 'alphanumeric',
minWidth: 100,
searchable: true,
},
{
Header: 'Payload',
accessor: 'payload',
Cell: PayloadCell,
disableSortBy: true,
searchable: true,
filterParsing: (value: IPayload) => value?.value,
},
{
Header: 'Overrides',
accessor: 'overrides',
Cell: OverridesCell,
disableSortBy: true,
searchable: true,
filterParsing: (value: IOverride[]) =>
value
?.map(
({ contextName, values }) =>
`${contextName}:${values.join()}`
)
.join('\n') || '',
},
{
Header: 'Weight',
accessor: 'weight',
Cell: ({
row: {
original: { name, weight },
},
}: any) => {
return (
<TextCell data-testid={`VARIANT_WEIGHT_${name}`}>
{calculateVariantWeight(weight)} %
</TextCell>
);
},
sortType: 'number',
},
{
Header: 'Type',
accessor: 'weightType',
Cell: TextCell,
sortType: 'alphanumeric',
},
{
Header: 'Actions',
id: 'Actions',
align: 'center',
Cell: ({
row: { original },
}: {
row: { original: IFeatureVariant };
}) => (
<VariantsActionCell
variant={original}
projectId={projectId}
editVariant={onEditVariant}
deleteVariant={onDeleteVariant}
/>
),
disableSortBy: true,
},
],
[projectId, variants, environment]
);
const initialState = useMemo(
() => ({
sortBy: [{ id: 'name', desc: false }],
}),
[]
);
const { data, getSearchText } = useSearch(columns, searchValue, variants);
const { headerGroups, rows, prepareRow, setHiddenColumns } = useTable(
{
columns: columns as any[],
data,
initialState,
sortTypes,
autoResetSortBy: false,
disableSortRemove: true,
disableMultiSort: true,
},
useSortBy,
useFlexLayout
);
useEffect(() => {
const hiddenColumns = [];
if (isLargeScreen) {
hiddenColumns.push('weightType');
}
if (isMediumScreen) {
hiddenColumns.push('payload', 'overrides');
}
setHiddenColumns(hiddenColumns);
}, [setHiddenColumns, isMediumScreen, isLargeScreen]);
return (
<StyledTableContainer>
<SearchHighlightProvider value={getSearchText(searchValue)}>
<VirtualizedTable
rows={rows}
headerGroups={headerGroups}
prepareRow={prepareRow}
/>
</SearchHighlightProvider>
<ConditionallyRender
condition={rows.length === 0}
show={
<ConditionallyRender
condition={searchValue?.length > 0}
show={
<TablePlaceholder>
No variants found matching &ldquo;
{searchValue}
&rdquo;
</TablePlaceholder>
}
/>
}
/>
</StyledTableContainer>
);
};

View File

@ -0,0 +1,63 @@
import { Link, styled, Typography } from '@mui/material';
import { Highlighter } from 'component/common/Highlighter/Highlighter';
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { IOverride } from 'interfaces/featureToggle';
const StyledItem = styled(Typography)(({ theme }) => ({
fontSize: theme.fontSizes.smallerBody,
}));
const StyledLink = styled(Link, {
shouldForwardProp: prop => prop !== 'highlighted',
})<{ highlighted?: boolean }>(({ theme, highlighted }) => ({
backgroundColor: highlighted ? theme.palette.highlight : 'transparent',
}));
interface IOverridesCellProps {
value?: IOverride[];
}
export const OverridesCell = ({ value: overrides }: IOverridesCellProps) => {
const { searchQuery } = useSearchHighlightContext();
if (!overrides || overrides.length === 0) return <TextCell />;
const overrideToString = (override: IOverride) =>
`${override.contextName}:${override.values.join()}`;
return (
<TextCell>
<HtmlTooltip
title={
<>
{overrides.map((override, index) => (
<StyledItem key={override.contextName + index}>
<Highlighter search={searchQuery}>
{overrideToString(override)}
</Highlighter>
</StyledItem>
))}
</>
}
>
<StyledLink
underline="always"
highlighted={
searchQuery.length > 0 &&
overrides
?.map(overrideToString)
.join('\n')
.toLowerCase()
.includes(searchQuery.toLowerCase())
}
>
{overrides.length === 1
? '1 override'
: `${overrides.length} overrides`}
</StyledLink>
</HtmlTooltip>
</TextCell>
);
};

View File

@ -0,0 +1,63 @@
import { Link, styled, Typography } from '@mui/material';
import { Highlighter } from 'component/common/Highlighter/Highlighter';
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { IPayload } from 'interfaces/featureToggle';
const StyledItem = styled(Typography)(({ theme }) => ({
fontSize: theme.fontSizes.smallerBody,
whiteSpace: 'pre-wrap',
}));
const StyledLink = styled(Link, {
shouldForwardProp: prop => prop !== 'highlighted',
})<{ highlighted?: boolean }>(({ theme, highlighted }) => ({
backgroundColor: highlighted ? theme.palette.highlight : 'transparent',
}));
interface IPayloadCellProps {
value?: IPayload;
}
export const PayloadCell = ({ value: payload }: IPayloadCellProps) => {
const { searchQuery } = useSearchHighlightContext();
if (!payload) return <TextCell />;
if (payload.type === 'string' && payload.value.length < 20) {
return (
<TextCell>
<Highlighter search={searchQuery}>{payload.value}</Highlighter>
</TextCell>
);
}
return (
<TextCell>
<HtmlTooltip
title={
<>
<StyledItem>
<Highlighter search={searchQuery}>
{payload.value}
</Highlighter>
</StyledItem>
</>
}
>
<StyledLink
underline="always"
highlighted={
searchQuery.length > 0 &&
payload.value
.toLowerCase()
.includes(searchQuery.toLowerCase())
}
>
{payload.type}
</StyledLink>
</HtmlTooltip>
</TextCell>
);
};

View File

@ -0,0 +1,48 @@
import { Edit, Delete } from '@mui/icons-material';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
import { UPDATE_FEATURE_VARIANTS } from 'component/providers/AccessProvider/permissions';
import { IFeatureVariant } from 'interfaces/featureToggle';
interface IVarintsActionCellProps {
projectId: string;
variant: IFeatureVariant;
editVariant: (variant: IFeatureVariant) => void;
deleteVariant: (variant: IFeatureVariant) => void;
}
export const VariantsActionCell = ({
projectId,
variant,
editVariant,
deleteVariant,
}: IVarintsActionCellProps) => {
return (
<ActionCell>
<PermissionIconButton
size="large"
data-testid={`VARIANT_EDIT_BUTTON_${variant.name}`}
permission={UPDATE_FEATURE_VARIANTS}
projectId={projectId}
onClick={() => editVariant(variant)}
tooltipProps={{
title: 'Edit variant',
}}
>
<Edit />
</PermissionIconButton>
<PermissionIconButton
size="large"
permission={UPDATE_FEATURE_VARIANTS}
data-testid={`VARIANT_DELETE_BUTTON_${variant.name}`}
projectId={projectId}
onClick={() => deleteVariant(variant)}
tooltipProps={{
title: 'Delete variant',
}}
>
<Delete />
</PermissionIconButton>
</ActionCell>
);
};

View File

@ -0,0 +1,82 @@
import { ListItemText, Menu, MenuItem, styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import { IFeatureEnvironment } from 'interfaces/featureToggle';
import { useState } from 'react';
const StyledListItemText = styled(ListItemText)(({ theme }) => ({
'& span': {
fontSize: theme.fontSizes.smallBody,
},
}));
interface IEnvironmentVariantsCopyFromProps {
environment: IFeatureEnvironment;
permission: string;
projectId: string;
onCopyVariantsFrom: (
fromEnvironment: IFeatureEnvironment,
toEnvironment: IFeatureEnvironment
) => void;
otherEnvsWithVariants: IFeatureEnvironment[];
}
export const EnvironmentVariantsCopyFrom = ({
environment,
permission,
projectId,
onCopyVariantsFrom,
otherEnvsWithVariants,
}: IEnvironmentVariantsCopyFromProps) => {
const [copyFromAnchorEl, setCopyFromAnchorEl] =
useState<null | HTMLElement>(null);
const copyFromOpen = Boolean(copyFromAnchorEl);
return (
<ConditionallyRender
condition={otherEnvsWithVariants.length > 0}
show={
<>
<PermissionButton
onClick={e => {
setCopyFromAnchorEl(e.currentTarget);
}}
id={`copy-from-menu-${environment.name}`}
aria-controls={copyFromOpen ? 'basic-menu' : undefined}
aria-haspopup="true"
aria-expanded={copyFromOpen ? 'true' : undefined}
variant="outlined"
permission={permission}
projectId={projectId}
>
Copy variants from
</PermissionButton>
<Menu
anchorEl={copyFromAnchorEl}
open={copyFromOpen}
onClose={() => setCopyFromAnchorEl(null)}
MenuListProps={{
'aria-labelledby': `copy-from-menu-${environment.name}`,
}}
>
{otherEnvsWithVariants.map(otherEnvironment => (
<MenuItem
key={otherEnvironment.name}
onClick={() =>
onCopyVariantsFrom(
otherEnvironment,
environment
)
}
>
<StyledListItemText>
{`Copy from ${otherEnvironment.name}`}
</StyledListItemText>
</MenuItem>
))}
</Menu>
</>
}
/>
);
};

View File

@ -0,0 +1,291 @@
import * as jsonpatch from 'fast-json-patch';
import { Alert, styled, useMediaQuery, useTheme } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import { Search } from 'component/common/Search/Search';
import { updateWeight } from 'component/common/util';
import { UPDATE_FEATURE_VARIANTS } from 'component/providers/AccessProvider/permissions';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { IFeatureEnvironment, IFeatureVariant } from 'interfaces/featureToggle';
import { useState } from 'react';
import { EnvironmentVariantModal } from './EnvironmentVariantModal/EnvironmentVariantModal';
import { EnvironmentVariantsCard } from './EnvironmentVariantsCard/EnvironmentVariantsCard';
import { VariantDeleteDialog } from './VariantDeleteDialog/VariantDeleteDialog';
import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
import { formatUnknownError } from 'utils/formatUnknownError';
import useToast from 'hooks/useToast';
import { EnvironmentVariantsCopyFrom } from './EnvironmentVariantsCopyFrom/EnvironmentVariantsCopyFrom';
const StyledAlert = styled(Alert)(({ theme }) => ({
marginBottom: theme.spacing(4),
'& code': {
fontWeight: theme.fontWeight.bold,
},
}));
const StyledButtonContainer = styled('div')(({ theme }) => ({
display: 'flex',
gap: theme.spacing(1.5),
}));
export const FeatureEnvironmentVariants = () => {
const { setToastData, setToastApiError } = useToast();
const theme = useTheme();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { feature, refetchFeature, loading } = useFeature(
projectId,
featureId
);
const { patchFeatureEnvironmentVariants } = useFeatureApi();
const [searchValue, setSearchValue] = useState('');
const [selectedEnvironment, setSelectedEnvironment] =
useState<IFeatureEnvironment>();
const [selectedVariant, setSelectedVariant] = useState<IFeatureVariant>();
const [modalOpen, setModalOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const createPatch = (
variants: IFeatureVariant[],
newVariants: IFeatureVariant[]
) => {
return jsonpatch.compare(variants, newVariants);
};
const getApiPayload = (
variants: IFeatureVariant[],
newVariants: IFeatureVariant[]
): {
patch: jsonpatch.Operation[];
error?: string;
} => {
try {
const updatedNewVariants = updateWeight(newVariants, 1000);
return { patch: createPatch(variants, updatedNewVariants) };
} catch (error: unknown) {
return { patch: [], error: formatUnknownError(error) };
}
};
const updateVariants = async (
environment: IFeatureEnvironment,
variants: IFeatureVariant[]
) => {
const environmentVariants = environment.variants ?? [];
const { patch } = getApiPayload(environmentVariants, variants);
if (patch.length === 0) return;
await patchFeatureEnvironmentVariants(
projectId,
featureId,
environment.name,
patch
);
refetchFeature();
};
const addVariant = (environment: IFeatureEnvironment) => {
setSelectedEnvironment(environment);
setSelectedVariant(undefined);
setModalOpen(true);
};
const editVariant = (
environment: IFeatureEnvironment,
variant: IFeatureVariant
) => {
setSelectedEnvironment(environment);
setSelectedVariant(variant);
setModalOpen(true);
};
const deleteVariant = (
environment: IFeatureEnvironment,
variant: IFeatureVariant
) => {
setSelectedEnvironment(environment);
setSelectedVariant(variant);
setDeleteOpen(true);
};
const onDeleteConfirm = async () => {
if (selectedEnvironment && selectedVariant) {
const variants = selectedEnvironment.variants ?? [];
const updatedVariants = variants.filter(
({ name }) => name !== selectedVariant.name
);
try {
await updateVariants(selectedEnvironment, updatedVariants);
setDeleteOpen(false);
setToastData({
title: `Variant deleted successfully`,
type: 'success',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
}
};
const onVariantConfirm = async (updatedVariants: IFeatureVariant[]) => {
if (selectedEnvironment) {
try {
await updateVariants(selectedEnvironment, updatedVariants);
setModalOpen(false);
setToastData({
title: `Variant ${
selectedVariant ? 'updated' : 'added'
} successfully`,
type: 'success',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
}
};
const onCopyVariantsFrom = async (
fromEnvironment: IFeatureEnvironment,
toEnvironment: IFeatureEnvironment
) => {
try {
const variants = fromEnvironment.variants ?? [];
await updateVariants(toEnvironment, variants);
setToastData({
title: 'Variants copied successfully',
type: 'success',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
const onUpdateStickiness = async (
environment: IFeatureEnvironment,
updatedVariants: IFeatureVariant[]
) => {
try {
await updateVariants(environment, updatedVariants);
setToastData({
title: 'Variant stickiness updated successfully',
type: 'success',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
return (
<PageContent
isLoading={loading}
header={
<PageHeader
title="Variants"
actions={
<ConditionallyRender
condition={!isSmallScreen}
show={
<>
<Search
initialValue={searchValue}
onChange={setSearchValue}
/>
</>
}
/>
}
>
<ConditionallyRender
condition={isSmallScreen}
show={
<Search
initialValue={searchValue}
onChange={setSearchValue}
/>
}
/>
</PageHeader>
}
>
<StyledAlert severity="info">
Variants allows you to return a variant object if the feature
toggle is considered enabled for the current request. When using
variants you should use the <code>getVariant()</code> method in
the Client SDK.
</StyledAlert>
{feature.environments.map(environment => {
const otherEnvsWithVariants = feature.environments.filter(
({ name, variants }) =>
name !== environment.name && variants?.length
);
return (
<EnvironmentVariantsCard
key={environment.name}
environment={environment}
searchValue={searchValue}
onAddVariant={() => addVariant(environment)}
onEditVariant={(variant: IFeatureVariant) =>
editVariant(environment, variant)
}
onDeleteVariant={(variant: IFeatureVariant) =>
deleteVariant(environment, variant)
}
onUpdateStickiness={(variants: IFeatureVariant[]) =>
onUpdateStickiness(environment, variants)
}
>
<ConditionallyRender
condition={environment.variants?.length === 0}
show={
<StyledButtonContainer>
<PermissionButton
onClick={() => addVariant(environment)}
variant="outlined"
permission={UPDATE_FEATURE_VARIANTS}
projectId={projectId}
>
Add variant
</PermissionButton>
<EnvironmentVariantsCopyFrom
environment={environment}
permission={UPDATE_FEATURE_VARIANTS}
projectId={projectId}
onCopyVariantsFrom={onCopyVariantsFrom}
otherEnvsWithVariants={
otherEnvsWithVariants
}
/>
</StyledButtonContainer>
}
/>
</EnvironmentVariantsCard>
);
})}
<EnvironmentVariantModal
environment={selectedEnvironment}
variant={selectedVariant}
open={modalOpen}
setOpen={setModalOpen}
getApiPayload={getApiPayload}
onConfirm={onVariantConfirm}
/>
<VariantDeleteDialog
variant={selectedVariant}
open={deleteOpen}
setOpen={setDeleteOpen}
onConfirm={onDeleteConfirm}
/>
</PageContent>
);
};

View File

@ -0,0 +1,42 @@
import { Alert, styled } from '@mui/material';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { IFeatureVariant } from 'interfaces/featureToggle';
const StyledLabel = styled('p')(({ theme }) => ({
marginTop: theme.spacing(3),
}));
interface IVariantDeleteDialogProps {
variant?: IFeatureVariant;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
onConfirm: () => void;
}
export const VariantDeleteDialog = ({
variant,
open,
setOpen,
onConfirm,
}: IVariantDeleteDialogProps) => {
return (
<Dialogue
title="Delete variant?"
open={open}
primaryButtonText="Delete variant"
secondaryButtonText="Close"
onClick={onConfirm}
onClose={() => {
setOpen(false);
}}
>
<Alert severity="error">
Deleting this variant will change which variant users receive.
</Alert>
<StyledLabel>
You are about to delete variant:{' '}
<strong>{variant?.name}</strong>
</StyledLabel>
</Dialogue>
);
};

View File

@ -1,9 +1,17 @@
import { FeatureVariantsList } from './FeatureVariantsList/FeatureVariantsList';
import { usePageTitle } from 'hooks/usePageTitle';
import { FeatureEnvironmentVariants } from './FeatureEnvironmentVariants/FeatureEnvironmentVariants';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
const FeatureVariants = () => {
usePageTitle('Variants');
const { uiConfig } = useUiConfig();
if (uiConfig.flags.variantsPerEnvironment) {
return <FeatureEnvironmentVariants />;
}
return <FeatureVariantsList />;
};

View File

@ -202,6 +202,26 @@ const useFeatureApi = () => {
}
};
const patchFeatureEnvironmentVariants = async (
projectId: string,
featureId: string,
environmentName: string,
patchPayload: Operation[]
) => {
const path = `api/admin/projects/${projectId}/features/${featureId}/environments/${environmentName}/variants`;
const req = createRequest(path, {
method: 'PATCH',
body: JSON.stringify(patchPayload),
});
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
const cloneFeatureToggle = async (
projectId: string,
featureId: string,
@ -235,6 +255,7 @@ const useFeatureApi = () => {
archiveFeatureToggle,
patchFeatureToggle,
patchFeatureVariants,
patchFeatureEnvironmentVariants,
cloneFeatureToggle,
loading,
};

View File

@ -1,9 +1,10 @@
import useSWR, { SWRConfiguration } from 'swr';
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { emptyFeature } from './emptyFeature';
import handleErrorResponses from '../httpErrorResponseHandler';
import { formatApiPath } from 'utils/formatPath';
import { IFeatureToggle } from 'interfaces/featureToggle';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
export interface IUseFeatureOutput {
feature: IFeatureToggle;
@ -25,9 +26,14 @@ export const useFeature = (
): IUseFeatureOutput => {
const path = formatFeatureApiPath(projectId, featureId);
const { uiConfig } = useUiConfig();
const {
flags: { variantsPerEnvironment },
} = uiConfig;
const { data, error, mutate } = useSWR<IFeatureResponse>(
['useFeature', path],
() => featureFetcher(path),
() => featureFetcher(path, variantsPerEnvironment),
options
);
@ -35,6 +41,10 @@ export const useFeature = (
mutate().catch(console.warn);
}, [mutate]);
useEffect(() => {
mutate();
}, [mutate, variantsPerEnvironment]);
return {
feature: data?.body || emptyFeature,
refetchFeature,
@ -45,9 +55,14 @@ export const useFeature = (
};
export const featureFetcher = async (
path: string
path: string,
variantsPerEnvironment?: boolean
): Promise<IFeatureResponse> => {
const res = await fetch(path);
const variantEnvironments = variantsPerEnvironment
? '?variantEnvironments=true'
: '';
const res = await fetch(path + variantEnvironments);
if (res.status === 404) {
return { status: 404 };

View File

@ -37,6 +37,7 @@ export interface IFeatureEnvironment {
name: string;
enabled: boolean;
strategies: IFeatureStrategy[];
variants?: IFeatureVariant[];
}
export interface IFeatureVariant {

View File

@ -43,6 +43,7 @@ export interface IFlags {
syncSSOGroups?: boolean;
changeRequests?: boolean;
cloneEnvironment?: boolean;
variantsPerEnvironment?: boolean;
}
export interface IVersionInfo {

View File

@ -76,6 +76,7 @@ exports[`should create default config 1`] = `
"responseTimeWithAppName": false,
"syncSSOGroups": false,
"toggleTagFiltering": false,
"variantsPerEnvironment": false,
},
},
"flagResolver": FlagResolver {
@ -90,6 +91,7 @@ exports[`should create default config 1`] = `
"responseTimeWithAppName": false,
"syncSSOGroups": false,
"toggleTagFiltering": false,
"variantsPerEnvironment": false,
},
"externalResolver": {
"isEnabled": [Function],

View File

@ -38,6 +38,10 @@ export const defaultExperimentalOptions = {
process.env.UNLEASH_EXPERIMENTAL_TOGGLE_TAG_FILTERING,
false,
),
variantsPerEnvironment: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_VARIANTS_PER_ENVIRONMENT,
false,
),
},
externalResolver: { isEnabled: (): boolean => false },
};
@ -53,6 +57,7 @@ export interface IExperimentalOptions {
syncSSOGroups?: boolean;
changeRequests?: boolean;
cloneEnvironment?: boolean;
variantsPerEnvironment?: boolean;
};
externalResolver: IExternalFlagResolver;
}

View File

@ -42,6 +42,7 @@ process.nextTick(async () => {
changeRequests: true,
cloneEnvironment: true,
toggleTagFiltering: true,
variantsPerEnvironment: true,
},
},
authentication: {

View File

@ -30,6 +30,7 @@ export function createTestConfig(config?: IUnleashOptions): IUnleashConfig {
syncSSOGroups: true,
changeRequests: true,
cloneEnvironment: true,
variantsPerEnvironment: true,
},
},
};