mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-26 13:48:33 +02:00
Variants per environment (frontend) (#2453)
 ## 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:
parent
31dc31fdf4
commit
93bd9d869a
@ -33,7 +33,6 @@ export const DateTimePicker = ({
|
|||||||
max,
|
max,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
InputProps,
|
|
||||||
...rest
|
...rest
|
||||||
}: IDateTimePickerProps) => {
|
}: IDateTimePickerProps) => {
|
||||||
const getDate = type === 'datetime' ? formatDateTime : formatDate;
|
const getDate = type === 'datetime' ? formatDateTime : formatDate;
|
||||||
|
@ -3,7 +3,7 @@ import { makeStyles } from 'tss-react/mui';
|
|||||||
export const useStyles = makeStyles()(theme => ({
|
export const useStyles = makeStyles()(theme => ({
|
||||||
helperText: {
|
helperText: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '35px',
|
bottom: '-1rem',
|
||||||
},
|
},
|
||||||
inputContainer: {
|
inputContainer: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { INPUT_ERROR_TEXT } from 'utils/testIds';
|
import { INPUT_ERROR_TEXT } from 'utils/testIds';
|
||||||
import { useStyles } from './Input.styles';
|
import { useStyles } from './Input.styles';
|
||||||
import React from 'react';
|
|
||||||
import { TextField, OutlinedTextFieldProps } from '@mui/material';
|
import { TextField, OutlinedTextFieldProps } from '@mui/material';
|
||||||
|
|
||||||
interface IInputProps extends Omit<OutlinedTextFieldProps, 'variant'> {
|
interface IInputProps extends Omit<OutlinedTextFieldProps, 'variant'> {
|
||||||
@ -26,7 +25,6 @@ const Input = ({
|
|||||||
className,
|
className,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
InputProps,
|
|
||||||
...rest
|
...rest
|
||||||
}: IInputProps) => {
|
}: IInputProps) => {
|
||||||
const { classes: styles } = useStyles();
|
const { classes: styles } = useStyles();
|
||||||
|
@ -94,12 +94,12 @@ enum ErrorField {
|
|||||||
PROJECTS = 'projects',
|
PROJECTS = 'projects',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ICreatePersonalAPITokenErrors {
|
interface IEnvironmentCloneModalErrors {
|
||||||
[ErrorField.NAME]?: string;
|
[ErrorField.NAME]?: string;
|
||||||
[ErrorField.PROJECTS]?: string;
|
[ErrorField.PROJECTS]?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ICreatePersonalAPITokenProps {
|
interface IEnvironmentCloneModalProps {
|
||||||
environment: IEnvironment;
|
environment: IEnvironment;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
@ -111,7 +111,7 @@ export const EnvironmentCloneModal = ({
|
|||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
newToken,
|
newToken,
|
||||||
}: ICreatePersonalAPITokenProps) => {
|
}: IEnvironmentCloneModalProps) => {
|
||||||
const { environments, refetchEnvironments } = useEnvironments();
|
const { environments, refetchEnvironments } = useEnvironments();
|
||||||
const { cloneEnvironment, loading } = useEnvironmentApi();
|
const { cloneEnvironment, loading } = useEnvironmentApi();
|
||||||
const { createToken } = useApiTokensApi();
|
const { createToken } = useApiTokensApi();
|
||||||
@ -126,7 +126,7 @@ export const EnvironmentCloneModal = ({
|
|||||||
const [clonePermissions, setClonePermissions] = useState(true);
|
const [clonePermissions, setClonePermissions] = useState(true);
|
||||||
const [apiTokenGeneration, setApiTokenGeneration] =
|
const [apiTokenGeneration, setApiTokenGeneration] =
|
||||||
useState<APITokenGeneration>(APITokenGeneration.LATER);
|
useState<APITokenGeneration>(APITokenGeneration.LATER);
|
||||||
const [errors, setErrors] = useState<ICreatePersonalAPITokenErrors>({});
|
const [errors, setErrors] = useState<IEnvironmentCloneModalErrors>({});
|
||||||
|
|
||||||
const clearError = (field: ErrorField) => {
|
const clearError = (field: ErrorField) => {
|
||||||
setErrors(errors => ({ ...errors, [field]: undefined }));
|
setErrors(errors => ({ ...errors, [field]: undefined }));
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -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'] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
@ -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];
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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 “
|
||||||
|
{searchValue}
|
||||||
|
”
|
||||||
|
</TablePlaceholder>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</StyledTableContainer>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -1,9 +1,17 @@
|
|||||||
import { FeatureVariantsList } from './FeatureVariantsList/FeatureVariantsList';
|
import { FeatureVariantsList } from './FeatureVariantsList/FeatureVariantsList';
|
||||||
import { usePageTitle } from 'hooks/usePageTitle';
|
import { usePageTitle } from 'hooks/usePageTitle';
|
||||||
|
import { FeatureEnvironmentVariants } from './FeatureEnvironmentVariants/FeatureEnvironmentVariants';
|
||||||
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
|
||||||
const FeatureVariants = () => {
|
const FeatureVariants = () => {
|
||||||
usePageTitle('Variants');
|
usePageTitle('Variants');
|
||||||
|
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
|
|
||||||
|
if (uiConfig.flags.variantsPerEnvironment) {
|
||||||
|
return <FeatureEnvironmentVariants />;
|
||||||
|
}
|
||||||
|
|
||||||
return <FeatureVariantsList />;
|
return <FeatureVariantsList />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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 (
|
const cloneFeatureToggle = async (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
featureId: string,
|
featureId: string,
|
||||||
@ -235,6 +255,7 @@ const useFeatureApi = () => {
|
|||||||
archiveFeatureToggle,
|
archiveFeatureToggle,
|
||||||
patchFeatureToggle,
|
patchFeatureToggle,
|
||||||
patchFeatureVariants,
|
patchFeatureVariants,
|
||||||
|
patchFeatureEnvironmentVariants,
|
||||||
cloneFeatureToggle,
|
cloneFeatureToggle,
|
||||||
loading,
|
loading,
|
||||||
};
|
};
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import useSWR, { SWRConfiguration } from 'swr';
|
import useSWR, { SWRConfiguration } from 'swr';
|
||||||
import { useCallback } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { emptyFeature } from './emptyFeature';
|
import { emptyFeature } from './emptyFeature';
|
||||||
import handleErrorResponses from '../httpErrorResponseHandler';
|
import handleErrorResponses from '../httpErrorResponseHandler';
|
||||||
import { formatApiPath } from 'utils/formatPath';
|
import { formatApiPath } from 'utils/formatPath';
|
||||||
import { IFeatureToggle } from 'interfaces/featureToggle';
|
import { IFeatureToggle } from 'interfaces/featureToggle';
|
||||||
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
|
||||||
export interface IUseFeatureOutput {
|
export interface IUseFeatureOutput {
|
||||||
feature: IFeatureToggle;
|
feature: IFeatureToggle;
|
||||||
@ -25,9 +26,14 @@ export const useFeature = (
|
|||||||
): IUseFeatureOutput => {
|
): IUseFeatureOutput => {
|
||||||
const path = formatFeatureApiPath(projectId, featureId);
|
const path = formatFeatureApiPath(projectId, featureId);
|
||||||
|
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
|
const {
|
||||||
|
flags: { variantsPerEnvironment },
|
||||||
|
} = uiConfig;
|
||||||
|
|
||||||
const { data, error, mutate } = useSWR<IFeatureResponse>(
|
const { data, error, mutate } = useSWR<IFeatureResponse>(
|
||||||
['useFeature', path],
|
['useFeature', path],
|
||||||
() => featureFetcher(path),
|
() => featureFetcher(path, variantsPerEnvironment),
|
||||||
options
|
options
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -35,6 +41,10 @@ export const useFeature = (
|
|||||||
mutate().catch(console.warn);
|
mutate().catch(console.warn);
|
||||||
}, [mutate]);
|
}, [mutate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mutate();
|
||||||
|
}, [mutate, variantsPerEnvironment]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
feature: data?.body || emptyFeature,
|
feature: data?.body || emptyFeature,
|
||||||
refetchFeature,
|
refetchFeature,
|
||||||
@ -45,9 +55,14 @@ export const useFeature = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const featureFetcher = async (
|
export const featureFetcher = async (
|
||||||
path: string
|
path: string,
|
||||||
|
variantsPerEnvironment?: boolean
|
||||||
): Promise<IFeatureResponse> => {
|
): Promise<IFeatureResponse> => {
|
||||||
const res = await fetch(path);
|
const variantEnvironments = variantsPerEnvironment
|
||||||
|
? '?variantEnvironments=true'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const res = await fetch(path + variantEnvironments);
|
||||||
|
|
||||||
if (res.status === 404) {
|
if (res.status === 404) {
|
||||||
return { status: 404 };
|
return { status: 404 };
|
||||||
|
@ -37,6 +37,7 @@ export interface IFeatureEnvironment {
|
|||||||
name: string;
|
name: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
strategies: IFeatureStrategy[];
|
strategies: IFeatureStrategy[];
|
||||||
|
variants?: IFeatureVariant[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IFeatureVariant {
|
export interface IFeatureVariant {
|
||||||
|
@ -43,6 +43,7 @@ export interface IFlags {
|
|||||||
syncSSOGroups?: boolean;
|
syncSSOGroups?: boolean;
|
||||||
changeRequests?: boolean;
|
changeRequests?: boolean;
|
||||||
cloneEnvironment?: boolean;
|
cloneEnvironment?: boolean;
|
||||||
|
variantsPerEnvironment?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IVersionInfo {
|
export interface IVersionInfo {
|
||||||
|
@ -76,6 +76,7 @@ exports[`should create default config 1`] = `
|
|||||||
"responseTimeWithAppName": false,
|
"responseTimeWithAppName": false,
|
||||||
"syncSSOGroups": false,
|
"syncSSOGroups": false,
|
||||||
"toggleTagFiltering": false,
|
"toggleTagFiltering": false,
|
||||||
|
"variantsPerEnvironment": false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"flagResolver": FlagResolver {
|
"flagResolver": FlagResolver {
|
||||||
@ -90,6 +91,7 @@ exports[`should create default config 1`] = `
|
|||||||
"responseTimeWithAppName": false,
|
"responseTimeWithAppName": false,
|
||||||
"syncSSOGroups": false,
|
"syncSSOGroups": false,
|
||||||
"toggleTagFiltering": false,
|
"toggleTagFiltering": false,
|
||||||
|
"variantsPerEnvironment": false,
|
||||||
},
|
},
|
||||||
"externalResolver": {
|
"externalResolver": {
|
||||||
"isEnabled": [Function],
|
"isEnabled": [Function],
|
||||||
|
@ -38,6 +38,10 @@ export const defaultExperimentalOptions = {
|
|||||||
process.env.UNLEASH_EXPERIMENTAL_TOGGLE_TAG_FILTERING,
|
process.env.UNLEASH_EXPERIMENTAL_TOGGLE_TAG_FILTERING,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
|
variantsPerEnvironment: parseEnvVarBoolean(
|
||||||
|
process.env.UNLEASH_EXPERIMENTAL_VARIANTS_PER_ENVIRONMENT,
|
||||||
|
false,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
externalResolver: { isEnabled: (): boolean => false },
|
externalResolver: { isEnabled: (): boolean => false },
|
||||||
};
|
};
|
||||||
@ -53,6 +57,7 @@ export interface IExperimentalOptions {
|
|||||||
syncSSOGroups?: boolean;
|
syncSSOGroups?: boolean;
|
||||||
changeRequests?: boolean;
|
changeRequests?: boolean;
|
||||||
cloneEnvironment?: boolean;
|
cloneEnvironment?: boolean;
|
||||||
|
variantsPerEnvironment?: boolean;
|
||||||
};
|
};
|
||||||
externalResolver: IExternalFlagResolver;
|
externalResolver: IExternalFlagResolver;
|
||||||
}
|
}
|
||||||
|
@ -42,6 +42,7 @@ process.nextTick(async () => {
|
|||||||
changeRequests: true,
|
changeRequests: true,
|
||||||
cloneEnvironment: true,
|
cloneEnvironment: true,
|
||||||
toggleTagFiltering: true,
|
toggleTagFiltering: true,
|
||||||
|
variantsPerEnvironment: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
authentication: {
|
authentication: {
|
||||||
|
@ -30,6 +30,7 @@ export function createTestConfig(config?: IUnleashOptions): IUnleashConfig {
|
|||||||
syncSSOGroups: true,
|
syncSSOGroups: true,
|
||||||
changeRequests: true,
|
changeRequests: true,
|
||||||
cloneEnvironment: true,
|
cloneEnvironment: true,
|
||||||
|
variantsPerEnvironment: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user