mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-25 00:07:47 +01:00
feat: new variants per env form (#3004)
https://linear.app/unleash/issue/2-647/adapt-current-variants-ui-to-better-align-with-cr ## About the changes Big refactor to the variants per environment UI/UX logic, making the variants management happen on a side modal. This makes it so variants per environment play a lot nicer with change requests. ![image](https://user-images.githubusercontent.com/14320932/214972213-32b9aba9-1390-47b3-a00a-8c4ada359953.png) <!-- (For internal contributors): Does it relate to an issue on public roadmap? --> Relates to [roadmap](https://github.com/orgs/Unleash/projects/10) item: [#2254](https://github.com/Unleash/unleash/issues/2254) ### Important files A big chunk of the changes is mostly moving things around or straight up removing them. - EnvironmentVariantModal - The modal itself that controls all of the variants that you're editing; - VariantForm - The extracted form for editing each of the variants;
This commit is contained in:
parent
f1984080a9
commit
816c8dbb46
@ -57,6 +57,7 @@
|
|||||||
"@types/react-test-renderer": "17.0.2",
|
"@types/react-test-renderer": "17.0.2",
|
||||||
"@types/react-timeago": "4.1.3",
|
"@types/react-timeago": "4.1.3",
|
||||||
"@types/semver": "7.3.13",
|
"@types/semver": "7.3.13",
|
||||||
|
"@types/uuid": "^9.0.0",
|
||||||
"@uiw/codemirror-theme-duotone": "4.19.6",
|
"@uiw/codemirror-theme-duotone": "4.19.6",
|
||||||
"@uiw/react-codemirror": "4.19.6",
|
"@uiw/react-codemirror": "4.19.6",
|
||||||
"@vitejs/plugin-react": "3.0.1",
|
"@vitejs/plugin-react": "3.0.1",
|
||||||
|
@ -4,5 +4,9 @@ export const useStyles = makeStyles()(theme => ({
|
|||||||
helperText: {
|
helperText: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: '-1rem',
|
bottom: '-1rem',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
maxWidth: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
@ -48,6 +48,7 @@ const Input = ({
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
FormHelperTextProps={{
|
FormHelperTextProps={{
|
||||||
['data-testid']: INPUT_ERROR_TEXT,
|
['data-testid']: INPUT_ERROR_TEXT,
|
||||||
|
title: errorText,
|
||||||
classes: {
|
classes: {
|
||||||
root: styles.helperText,
|
root: styles.helperText,
|
||||||
},
|
},
|
||||||
|
@ -3,6 +3,7 @@ import { IUiConfig } from 'interfaces/uiConfig';
|
|||||||
import { INavigationMenuItem } from 'interfaces/route';
|
import { INavigationMenuItem } from 'interfaces/route';
|
||||||
import { IFeatureVariant } from 'interfaces/featureToggle';
|
import { IFeatureVariant } from 'interfaces/featureToggle';
|
||||||
import { format, isValid } from 'date-fns';
|
import { format, isValid } from 'date-fns';
|
||||||
|
import { IFeatureVariantEdit } from 'component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/EnvironmentVariantsModal';
|
||||||
|
|
||||||
export const filterByConfig =
|
export const filterByConfig =
|
||||||
(config: IUiConfig) => (r: INavigationMenuItem) => {
|
(config: IUiConfig) => (r: INavigationMenuItem) => {
|
||||||
@ -90,6 +91,40 @@ export function updateWeight(variants: IFeatureVariant[], totalWeight: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateWeightEdit(
|
||||||
|
variants: IFeatureVariantEdit[],
|
||||||
|
totalWeight: number
|
||||||
|
) {
|
||||||
|
if (variants.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const { remainingPercentage, variableVariantCount } = variants.reduce(
|
||||||
|
({ remainingPercentage, variableVariantCount }, variant) => {
|
||||||
|
if (variant.weight && variant.weightType === weightTypes.FIX) {
|
||||||
|
remainingPercentage -= Number(variant.weight);
|
||||||
|
} else {
|
||||||
|
variableVariantCount += 1;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
remainingPercentage,
|
||||||
|
variableVariantCount,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ remainingPercentage: totalWeight, variableVariantCount: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const percentage = parseInt(
|
||||||
|
String(remainingPercentage / variableVariantCount)
|
||||||
|
);
|
||||||
|
|
||||||
|
return variants.map(variant => {
|
||||||
|
if (variant.weightType !== weightTypes.FIX) {
|
||||||
|
variant.weight = percentage;
|
||||||
|
}
|
||||||
|
return variant;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const modalStyles = {
|
export const modalStyles = {
|
||||||
overlay: {
|
overlay: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
@ -1,570 +0,0 @@
|
|||||||
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';
|
|
||||||
import { WeightType } from 'constants/variantTypes';
|
|
||||||
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
|
|
||||||
import { useChangeRequestInReviewWarning } from 'hooks/useChangeRequestInReviewWarning';
|
|
||||||
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
|
|
||||||
|
|
||||||
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 StyledCRAlert = styled(Alert)(({ theme }) => ({
|
|
||||||
marginBottom: theme.spacing(2),
|
|
||||||
}));
|
|
||||||
|
|
||||||
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' },
|
|
||||||
];
|
|
||||||
|
|
||||||
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 };
|
|
||||||
getCrPayload: (variants: IFeatureVariant[]) => {
|
|
||||||
feature: string;
|
|
||||||
action: 'patchVariant';
|
|
||||||
payload: { variants: IFeatureVariant[] };
|
|
||||||
};
|
|
||||||
onConfirm: (updatedVariants: IFeatureVariant[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EnvironmentVariantModal = ({
|
|
||||||
environment,
|
|
||||||
variant,
|
|
||||||
open,
|
|
||||||
setOpen,
|
|
||||||
getApiPayload,
|
|
||||||
getCrPayload,
|
|
||||||
onConfirm,
|
|
||||||
}: IEnvironmentVariantModalProps) => {
|
|
||||||
const projectId = useRequiredPathParam('projectId');
|
|
||||||
const featureId = useRequiredPathParam('featureId');
|
|
||||||
|
|
||||||
const { uiConfig } = useUiConfig();
|
|
||||||
|
|
||||||
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
|
|
||||||
const { data } = usePendingChangeRequests(projectId);
|
|
||||||
const { changeRequestInReviewOrApproved, alert } =
|
|
||||||
useChangeRequestInReviewWarning(data);
|
|
||||||
|
|
||||||
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());
|
|
||||||
const crPayload = getCrPayload(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 = () =>
|
|
||||||
isChangeRequest
|
|
||||||
? `curl --location --request POST '${
|
|
||||||
uiConfig.unleashUrl
|
|
||||||
}/api/admin/projects/${projectId}/environments/${
|
|
||||||
environment?.name
|
|
||||||
}/change-requests' \\
|
|
||||||
--header 'Authorization: INSERT_API_KEY' \\
|
|
||||||
--header 'Content-Type: application/json' \\
|
|
||||||
--data-raw '${JSON.stringify(crPayload, undefined, 2)}'`
|
|
||||||
: `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;
|
|
||||||
if (percentage.match(/\.[0-9]{2,}$/)) 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: [] },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasChangeRequestInReviewForEnvironment =
|
|
||||||
changeRequestInReviewOrApproved(environment?.name || '');
|
|
||||||
|
|
||||||
const changeRequestButtonText = hasChangeRequestInReviewForEnvironment
|
|
||||||
? 'Add to existing change request'
|
|
||||||
: 'Add change to draft';
|
|
||||||
|
|
||||||
const isChangeRequest =
|
|
||||||
isChangeRequestConfigured(environment?.name || '') &&
|
|
||||||
uiConfig.flags.crOnVariants;
|
|
||||||
|
|
||||||
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/reference/feature-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>
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={hasChangeRequestInReviewForEnvironment}
|
|
||||||
show={alert}
|
|
||||||
elseShow={
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={Boolean(isChangeRequest)}
|
|
||||||
show={
|
|
||||||
<StyledCRAlert severity="info">
|
|
||||||
<strong>Change requests</strong> are
|
|
||||||
enabled
|
|
||||||
{environment
|
|
||||||
? ` for ${environment.name}`
|
|
||||||
: ''}
|
|
||||||
. Your changes need to be approved
|
|
||||||
before they will be live. All the
|
|
||||||
changes you do now will be added
|
|
||||||
into a draft that you can submit for
|
|
||||||
review.
|
|
||||||
</StyledCRAlert>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<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}
|
|
||||||
>
|
|
||||||
{isChangeRequest
|
|
||||||
? changeRequestButtonText
|
|
||||||
: editing
|
|
||||||
? 'Save variant'
|
|
||||||
: 'Add variant'}
|
|
||||||
</Button>
|
|
||||||
<StyledCancelButton
|
|
||||||
onClick={() => {
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</StyledCancelButton>
|
|
||||||
</StyledButtonContainer>
|
|
||||||
</StyledForm>
|
|
||||||
</FormTemplate>
|
|
||||||
</SidebarModal>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,11 +1,9 @@
|
|||||||
import { CloudCircle } from '@mui/icons-material';
|
import { CloudCircle } from '@mui/icons-material';
|
||||||
import { styled } from '@mui/material';
|
import { styled } from '@mui/material';
|
||||||
import { IFeatureEnvironment, IFeatureVariant } from 'interfaces/featureToggle';
|
import { IFeatureEnvironment } from 'interfaces/featureToggle';
|
||||||
import { EnvironmentVariantsTable } from './EnvironmentVariantsTable/EnvironmentVariantsTable';
|
import { EnvironmentVariantsTable } from './EnvironmentVariantsTable/EnvironmentVariantsTable';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
|
import { Badge } from 'component/common/Badge/Badge';
|
||||||
import { useMemo } from 'react';
|
|
||||||
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
|
|
||||||
|
|
||||||
const StyledCard = styled('div')(({ theme }) => ({
|
const StyledCard = styled('div')(({ theme }) => ({
|
||||||
padding: theme.spacing(3),
|
padding: theme.spacing(3),
|
||||||
@ -16,7 +14,7 @@ const StyledCard = styled('div')(({ theme }) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const StyledHeader = styled('div')(() => ({
|
const StyledHeader = styled('div')({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
@ -24,7 +22,7 @@ const StyledHeader = styled('div')(() => ({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
}));
|
});
|
||||||
|
|
||||||
const StyledCloudCircle = styled(CloudCircle, {
|
const StyledCloudCircle = styled(CloudCircle, {
|
||||||
shouldForwardProp: prop => prop !== 'deprecated',
|
shouldForwardProp: prop => prop !== 'deprecated',
|
||||||
@ -41,6 +39,7 @@ const StyledName = styled('span', {
|
|||||||
? theme.palette.text.secondary
|
? theme.palette.text.secondary
|
||||||
: theme.palette.text.primary,
|
: theme.palette.text.primary,
|
||||||
marginLeft: theme.spacing(1.25),
|
marginLeft: theme.spacing(1.25),
|
||||||
|
fontWeight: theme.fontWeight.bold,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const StyledDescription = styled('p')(({ theme }) => ({
|
const StyledDescription = styled('p')(({ theme }) => ({
|
||||||
@ -49,57 +48,27 @@ const StyledDescription = styled('p')(({ theme }) => ({
|
|||||||
marginBottom: theme.spacing(1.5),
|
marginBottom: theme.spacing(1.5),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const StyledGeneralSelect = styled(GeneralSelect)(({ theme }) => ({
|
const StyledStickinessContainer = styled('div')(({ theme }) => ({
|
||||||
minWidth: theme.spacing(20),
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing(1.5),
|
||||||
|
marginBottom: theme.spacing(0.5),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
interface IEnvironmentVariantsCardProps {
|
interface IEnvironmentVariantsCardProps {
|
||||||
environment: IFeatureEnvironment;
|
environment: IFeatureEnvironment;
|
||||||
searchValue: string;
|
searchValue: string;
|
||||||
onEditVariant: (variant: IFeatureVariant) => void;
|
|
||||||
onDeleteVariant: (variant: IFeatureVariant) => void;
|
|
||||||
onUpdateStickiness: (variant: IFeatureVariant[]) => void;
|
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EnvironmentVariantsCard = ({
|
export const EnvironmentVariantsCard = ({
|
||||||
environment,
|
environment,
|
||||||
searchValue,
|
searchValue,
|
||||||
onEditVariant,
|
|
||||||
onDeleteVariant,
|
|
||||||
onUpdateStickiness,
|
|
||||||
children,
|
children,
|
||||||
}: IEnvironmentVariantsCardProps) => {
|
}: IEnvironmentVariantsCardProps) => {
|
||||||
const { context } = useUnleashContext();
|
|
||||||
|
|
||||||
const variants = environment.variants ?? [];
|
const variants = environment.variants ?? [];
|
||||||
const stickiness = variants[0]?.stickiness || 'default';
|
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 (
|
return (
|
||||||
<StyledCard>
|
<StyledCard>
|
||||||
<StyledHeader>
|
<StyledHeader>
|
||||||
@ -118,14 +87,15 @@ export const EnvironmentVariantsCard = ({
|
|||||||
<EnvironmentVariantsTable
|
<EnvironmentVariantsTable
|
||||||
environment={environment}
|
environment={environment}
|
||||||
searchValue={searchValue}
|
searchValue={searchValue}
|
||||||
onEditVariant={onEditVariant}
|
|
||||||
onDeleteVariant={onDeleteVariant}
|
|
||||||
/>
|
/>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={variants.length > 1}
|
condition={variants.length > 1}
|
||||||
show={
|
show={
|
||||||
<>
|
<>
|
||||||
<p>Stickiness</p>
|
<StyledStickinessContainer>
|
||||||
|
<p>Stickiness:</p>
|
||||||
|
<Badge>{stickiness}</Badge>
|
||||||
|
</StyledStickinessContainer>
|
||||||
<StyledDescription>
|
<StyledDescription>
|
||||||
By overriding the stickiness you can
|
By overriding the stickiness you can
|
||||||
control which parameter is used to
|
control which parameter is used to
|
||||||
@ -139,11 +109,6 @@ export const EnvironmentVariantsCard = ({
|
|||||||
Read more
|
Read more
|
||||||
</a>
|
</a>
|
||||||
</StyledDescription>
|
</StyledDescription>
|
||||||
<StyledGeneralSelect
|
|
||||||
options={options}
|
|
||||||
value={stickiness}
|
|
||||||
onChange={onStickinessChange}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
@ -20,7 +20,6 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
|||||||
import { useSearch } from 'hooks/useSearch';
|
import { useSearch } from 'hooks/useSearch';
|
||||||
import {
|
import {
|
||||||
IFeatureEnvironment,
|
IFeatureEnvironment,
|
||||||
IFeatureVariant,
|
|
||||||
IOverride,
|
IOverride,
|
||||||
IPayload,
|
IPayload,
|
||||||
} from 'interfaces/featureToggle';
|
} from 'interfaces/featureToggle';
|
||||||
@ -29,9 +28,7 @@ import { useSortBy, useTable } from 'react-table';
|
|||||||
import { sortTypes } from 'utils/sortTypes';
|
import { sortTypes } from 'utils/sortTypes';
|
||||||
import { PayloadCell } from './PayloadCell/PayloadCell';
|
import { PayloadCell } from './PayloadCell/PayloadCell';
|
||||||
import { OverridesCell } from './OverridesCell/OverridesCell';
|
import { OverridesCell } from './OverridesCell/OverridesCell';
|
||||||
import { VariantsActionCell } from './VariantsActionsCell/VariantsActionsCell';
|
|
||||||
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
|
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
|
||||||
import { WeightType } from 'constants/variantTypes';
|
|
||||||
|
|
||||||
const StyledTableContainer = styled('div')(({ theme }) => ({
|
const StyledTableContainer = styled('div')(({ theme }) => ({
|
||||||
margin: theme.spacing(3, 0),
|
margin: theme.spacing(3, 0),
|
||||||
@ -40,15 +37,11 @@ const StyledTableContainer = styled('div')(({ theme }) => ({
|
|||||||
interface IEnvironmentVariantsTableProps {
|
interface IEnvironmentVariantsTableProps {
|
||||||
environment: IFeatureEnvironment;
|
environment: IFeatureEnvironment;
|
||||||
searchValue: string;
|
searchValue: string;
|
||||||
onEditVariant: (variant: IFeatureVariant) => void;
|
|
||||||
onDeleteVariant: (variant: IFeatureVariant) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EnvironmentVariantsTable = ({
|
export const EnvironmentVariantsTable = ({
|
||||||
environment,
|
environment,
|
||||||
searchValue,
|
searchValue,
|
||||||
onEditVariant,
|
|
||||||
onDeleteVariant,
|
|
||||||
}: IEnvironmentVariantsTableProps) => {
|
}: IEnvironmentVariantsTableProps) => {
|
||||||
const projectId = useRequiredPathParam('projectId');
|
const projectId = useRequiredPathParam('projectId');
|
||||||
|
|
||||||
@ -108,30 +101,11 @@ export const EnvironmentVariantsTable = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: 'Type',
|
Header: 'Type',
|
||||||
accessor: 'weightType',
|
accessor: (row: any) =>
|
||||||
|
row.weightType === 'fix' ? 'Fixed' : 'Variable',
|
||||||
Cell: TextCell,
|
Cell: TextCell,
|
||||||
sortType: 'alphanumeric',
|
sortType: 'alphanumeric',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
Header: 'Actions',
|
|
||||||
id: 'Actions',
|
|
||||||
align: 'center',
|
|
||||||
Cell: ({
|
|
||||||
row: { original },
|
|
||||||
}: {
|
|
||||||
row: { original: IFeatureVariant };
|
|
||||||
}) => (
|
|
||||||
<VariantsActionCell
|
|
||||||
variant={original}
|
|
||||||
projectId={projectId}
|
|
||||||
isLastVariableVariant={isProtectedVariant(original)}
|
|
||||||
environmentId={environment.name}
|
|
||||||
editVariant={onEditVariant}
|
|
||||||
deleteVariant={onDeleteVariant}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
disableSortBy: true,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
[projectId, variants, environment]
|
[projectId, variants, environment]
|
||||||
);
|
);
|
||||||
@ -143,23 +117,6 @@ export const EnvironmentVariantsTable = ({
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const isProtectedVariant = (variant: IFeatureVariant): boolean => {
|
|
||||||
const isVariable = variant.weightType === WeightType.VARIABLE;
|
|
||||||
|
|
||||||
const atLeastOneFixedVariant = variants.some(variant => {
|
|
||||||
return variant.weightType === WeightType.FIX;
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasOnlyOneVariableVariant =
|
|
||||||
variants.filter(variant => {
|
|
||||||
return variant.weightType === WeightType.VARIABLE;
|
|
||||||
}).length == 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
atLeastOneFixedVariant && hasOnlyOneVariableVariant && isVariable
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const { data, getSearchText } = useSearch(columns, searchValue, variants);
|
const { data, getSearchText } = useSearch(columns, searchValue, variants);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
@ -1,57 +0,0 @@
|
|||||||
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_ENVIRONMENT_VARIANTS } from 'component/providers/AccessProvider/permissions';
|
|
||||||
import { IFeatureVariant } from 'interfaces/featureToggle';
|
|
||||||
|
|
||||||
interface IVariantsActionCellProps {
|
|
||||||
projectId: string;
|
|
||||||
environmentId: string;
|
|
||||||
variant: IFeatureVariant;
|
|
||||||
isLastVariableVariant: boolean;
|
|
||||||
editVariant: (variant: IFeatureVariant) => void;
|
|
||||||
deleteVariant: (variant: IFeatureVariant) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const VariantsActionCell = ({
|
|
||||||
projectId,
|
|
||||||
environmentId,
|
|
||||||
variant,
|
|
||||||
isLastVariableVariant,
|
|
||||||
editVariant,
|
|
||||||
deleteVariant,
|
|
||||||
}: IVariantsActionCellProps) => {
|
|
||||||
return (
|
|
||||||
<ActionCell>
|
|
||||||
<PermissionIconButton
|
|
||||||
size="large"
|
|
||||||
data-testid={`VARIANT_EDIT_BUTTON_${variant.name}`}
|
|
||||||
permission={UPDATE_FEATURE_ENVIRONMENT_VARIANTS}
|
|
||||||
projectId={projectId}
|
|
||||||
environmentId={environmentId}
|
|
||||||
onClick={() => editVariant(variant)}
|
|
||||||
tooltipProps={{
|
|
||||||
title: 'Edit variant',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Edit />
|
|
||||||
</PermissionIconButton>
|
|
||||||
<PermissionIconButton
|
|
||||||
size="large"
|
|
||||||
permission={UPDATE_FEATURE_ENVIRONMENT_VARIANTS}
|
|
||||||
data-testid={`VARIANT_DELETE_BUTTON_${variant.name}`}
|
|
||||||
projectId={projectId}
|
|
||||||
disabled={isLastVariableVariant}
|
|
||||||
environmentId={environmentId}
|
|
||||||
onClick={() => deleteVariant(variant)}
|
|
||||||
tooltipProps={{
|
|
||||||
title: isLastVariableVariant
|
|
||||||
? 'You need to have at least one variable variant'
|
|
||||||
: 'Delete variant',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Delete />
|
|
||||||
</PermissionIconButton>
|
|
||||||
</ActionCell>
|
|
||||||
);
|
|
||||||
};
|
|
@ -0,0 +1,424 @@
|
|||||||
|
import { Alert, Button, 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, useMemo, useState } from 'react';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { IFeatureEnvironment, IFeatureVariant } from 'interfaces/featureToggle';
|
||||||
|
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||||
|
import { Operation } from 'fast-json-patch';
|
||||||
|
import { CloudCircle } from '@mui/icons-material';
|
||||||
|
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
|
||||||
|
import { useChangeRequestInReviewWarning } from 'hooks/useChangeRequestInReviewWarning';
|
||||||
|
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
|
||||||
|
import { VariantForm } from './VariantForm/VariantForm';
|
||||||
|
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
|
||||||
|
import { UPDATE_FEATURE_ENVIRONMENT_VARIANTS } from 'component/providers/AccessProvider/permissions';
|
||||||
|
import { WeightType } from 'constants/variantTypes';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
|
||||||
|
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
|
||||||
|
import { updateWeightEdit } from 'component/common/util';
|
||||||
|
|
||||||
|
const StyledFormSubtitle = styled('div')(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
'& > div': {
|
||||||
|
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),
|
||||||
|
fontSize: theme.fontSizes.mainHeader,
|
||||||
|
fontWeight: theme.fontWeight.bold,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledForm = styled('form')(() => ({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledCRAlert = styled(Alert)(({ theme }) => ({
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledAlert = styled(Alert)(({ theme }) => ({
|
||||||
|
marginTop: theme.spacing(4),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledVariantForms = styled('div')(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column-reverse',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledStickinessContainer = styled('div')(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing(1.5),
|
||||||
|
marginBottom: theme.spacing(0.5),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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),
|
||||||
|
width: '100%',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledButtonContainer = styled('div')(({ theme }) => ({
|
||||||
|
marginTop: 'auto',
|
||||||
|
paddingTop: theme.spacing(4),
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledCancelButton = styled(Button)(({ theme }) => ({
|
||||||
|
marginLeft: theme.spacing(3),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export type IFeatureVariantEdit = IFeatureVariant & {
|
||||||
|
isValid: boolean;
|
||||||
|
new: boolean;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IEnvironmentVariantModalProps {
|
||||||
|
environment?: IFeatureEnvironment;
|
||||||
|
open: boolean;
|
||||||
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
getApiPayload: (
|
||||||
|
variants: IFeatureVariant[],
|
||||||
|
newVariants: IFeatureVariant[]
|
||||||
|
) => { patch: Operation[]; error?: string };
|
||||||
|
getCrPayload: (variants: IFeatureVariant[]) => {
|
||||||
|
feature: string;
|
||||||
|
action: 'patchVariant';
|
||||||
|
payload: { variants: IFeatureVariant[] };
|
||||||
|
};
|
||||||
|
onConfirm: (updatedVariants: IFeatureVariant[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EnvironmentVariantsModal = ({
|
||||||
|
environment,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
getApiPayload,
|
||||||
|
getCrPayload,
|
||||||
|
onConfirm,
|
||||||
|
}: IEnvironmentVariantModalProps) => {
|
||||||
|
const projectId = useRequiredPathParam('projectId');
|
||||||
|
const featureId = useRequiredPathParam('featureId');
|
||||||
|
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
|
const { context } = useUnleashContext();
|
||||||
|
|
||||||
|
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
|
||||||
|
const { data } = usePendingChangeRequests(projectId);
|
||||||
|
const { changeRequestInReviewOrApproved, alert } =
|
||||||
|
useChangeRequestInReviewWarning(data);
|
||||||
|
|
||||||
|
const oldVariants = environment?.variants || [];
|
||||||
|
const [variantsEdit, setVariantsEdit] = useState<IFeatureVariantEdit[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVariantsEdit(
|
||||||
|
oldVariants.length
|
||||||
|
? oldVariants.map(oldVariant => ({
|
||||||
|
...oldVariant,
|
||||||
|
isValid: true,
|
||||||
|
new: false,
|
||||||
|
id: uuidv4(),
|
||||||
|
}))
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
name: '',
|
||||||
|
weightType: WeightType.VARIABLE,
|
||||||
|
weight: 0,
|
||||||
|
overrides: [],
|
||||||
|
stickiness:
|
||||||
|
variantsEdit?.length > 0
|
||||||
|
? variantsEdit[0].stickiness
|
||||||
|
: 'default',
|
||||||
|
new: true,
|
||||||
|
isValid: false,
|
||||||
|
id: uuidv4(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const updateVariant = (updatedVariant: IFeatureVariantEdit, id: string) => {
|
||||||
|
setVariantsEdit(prevVariants =>
|
||||||
|
updateWeightEdit(
|
||||||
|
prevVariants.map(prevVariant =>
|
||||||
|
prevVariant.id === id ? updatedVariant : prevVariant
|
||||||
|
),
|
||||||
|
1000
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const variants = variantsEdit.map(
|
||||||
|
({ new: _, isValid: __, id: ___, ...rest }) => rest
|
||||||
|
);
|
||||||
|
|
||||||
|
const apiPayload = getApiPayload(oldVariants, variants);
|
||||||
|
const crPayload = getCrPayload(variants);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onConfirm(variants);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatApiCode = () =>
|
||||||
|
isChangeRequest
|
||||||
|
? `curl --location --request POST '${
|
||||||
|
uiConfig.unleashUrl
|
||||||
|
}/api/admin/projects/${projectId}/environments/${
|
||||||
|
environment?.name
|
||||||
|
}/change-requests' \\
|
||||||
|
--header 'Authorization: INSERT_API_KEY' \\
|
||||||
|
--header 'Content-Type: application/json' \\
|
||||||
|
--data-raw '${JSON.stringify(crPayload, undefined, 2)}'`
|
||||||
|
: `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 isValid = variantsEdit.every(({ isValid }) => isValid);
|
||||||
|
|
||||||
|
const hasChangeRequestInReviewForEnvironment =
|
||||||
|
changeRequestInReviewOrApproved(environment?.name || '');
|
||||||
|
|
||||||
|
const changeRequestButtonText = hasChangeRequestInReviewForEnvironment
|
||||||
|
? 'Add to existing change request'
|
||||||
|
: 'Add change to draft';
|
||||||
|
|
||||||
|
const isChangeRequest =
|
||||||
|
isChangeRequestConfigured(environment?.name || '') &&
|
||||||
|
uiConfig.flags.crOnVariants;
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
setVariantsEdit(prevVariants =>
|
||||||
|
prevVariants.map(prevVariant => ({
|
||||||
|
...prevVariant,
|
||||||
|
stickiness,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onStickinessChange = (value: string) => {
|
||||||
|
updateStickiness(value).catch(console.warn);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [error, setError] = useState<string | undefined>();
|
||||||
|
useEffect(() => {
|
||||||
|
setError(undefined);
|
||||||
|
if (apiPayload.error) {
|
||||||
|
setError(apiPayload.error);
|
||||||
|
}
|
||||||
|
}, [apiPayload.error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarModal
|
||||||
|
open={open}
|
||||||
|
onClose={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
label=""
|
||||||
|
>
|
||||||
|
<FormTemplate
|
||||||
|
modal
|
||||||
|
title=""
|
||||||
|
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/reference/feature-toggle-variants"
|
||||||
|
documentationLinkLabel="Feature toggle variants documentation"
|
||||||
|
formatApiCode={formatApiCode}
|
||||||
|
loading={!open}
|
||||||
|
>
|
||||||
|
<StyledFormSubtitle>
|
||||||
|
<div>
|
||||||
|
<StyledCloudCircle deprecated={!environment?.enabled} />
|
||||||
|
<StyledName deprecated={!environment?.enabled}>
|
||||||
|
{environment?.name}
|
||||||
|
</StyledName>
|
||||||
|
</div>
|
||||||
|
<PermissionButton
|
||||||
|
onClick={() =>
|
||||||
|
setVariantsEdit(variantsEdit => [
|
||||||
|
...variantsEdit,
|
||||||
|
{
|
||||||
|
name: '',
|
||||||
|
weightType: WeightType.VARIABLE,
|
||||||
|
weight: 0,
|
||||||
|
overrides: [],
|
||||||
|
stickiness:
|
||||||
|
variantsEdit?.length > 0
|
||||||
|
? variantsEdit[0].stickiness
|
||||||
|
: 'default',
|
||||||
|
new: true,
|
||||||
|
isValid: false,
|
||||||
|
id: uuidv4(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
variant="outlined"
|
||||||
|
permission={UPDATE_FEATURE_ENVIRONMENT_VARIANTS}
|
||||||
|
projectId={projectId}
|
||||||
|
environmentId={environment?.name}
|
||||||
|
>
|
||||||
|
Add variant
|
||||||
|
</PermissionButton>
|
||||||
|
</StyledFormSubtitle>
|
||||||
|
<StyledForm onSubmit={handleSubmit}>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={hasChangeRequestInReviewForEnvironment}
|
||||||
|
show={alert}
|
||||||
|
elseShow={
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={Boolean(isChangeRequest)}
|
||||||
|
show={
|
||||||
|
<StyledCRAlert severity="info">
|
||||||
|
<strong>Change requests</strong> are
|
||||||
|
enabled
|
||||||
|
{environment
|
||||||
|
? ` for ${environment.name}`
|
||||||
|
: ''}
|
||||||
|
. Your changes need to be approved
|
||||||
|
before they will be live. All the
|
||||||
|
changes you do now will be added into a
|
||||||
|
draft that you can submit for review.
|
||||||
|
</StyledCRAlert>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StyledVariantForms>
|
||||||
|
{variantsEdit.map(variant => (
|
||||||
|
<VariantForm
|
||||||
|
key={variant.id}
|
||||||
|
variant={variant}
|
||||||
|
variants={variantsEdit}
|
||||||
|
updateVariant={updatedVariant =>
|
||||||
|
updateVariant(updatedVariant, variant.id)
|
||||||
|
}
|
||||||
|
removeVariant={() =>
|
||||||
|
setVariantsEdit(variantsEdit =>
|
||||||
|
updateWeightEdit(
|
||||||
|
variantsEdit.filter(
|
||||||
|
v => v.id !== variant.id
|
||||||
|
),
|
||||||
|
1000
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
projectId={projectId}
|
||||||
|
apiPayload={apiPayload}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</StyledVariantForms>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={variantsEdit.length > 0}
|
||||||
|
show={
|
||||||
|
<>
|
||||||
|
<StyledStickinessContainer>
|
||||||
|
<p>Stickiness</p>
|
||||||
|
</StyledStickinessContainer>
|
||||||
|
<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/reference/feature-toggle-variants"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Read more
|
||||||
|
</a>
|
||||||
|
</StyledDescription>
|
||||||
|
<div>
|
||||||
|
<StyledGeneralSelect
|
||||||
|
options={options}
|
||||||
|
value={stickiness}
|
||||||
|
onChange={onStickinessChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
elseShow={
|
||||||
|
<StyledDescription>
|
||||||
|
This environment has no variants. Get started by
|
||||||
|
adding a variant.
|
||||||
|
</StyledDescription>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StyledAlert severity="error" hidden={!Boolean(error)}>
|
||||||
|
<strong>Error: </strong>
|
||||||
|
{error}
|
||||||
|
</StyledAlert>
|
||||||
|
|
||||||
|
<StyledButtonContainer>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
disabled={!isValid}
|
||||||
|
>
|
||||||
|
{isChangeRequest
|
||||||
|
? changeRequestButtonText
|
||||||
|
: 'Save variants'}
|
||||||
|
</Button>
|
||||||
|
<StyledCancelButton
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</StyledCancelButton>
|
||||||
|
</StyledButtonContainer>
|
||||||
|
</StyledForm>
|
||||||
|
</FormTemplate>
|
||||||
|
</SidebarModal>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,417 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Input from 'component/common/Input/Input';
|
||||||
|
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
|
||||||
|
import SelectMenu from 'component/common/select';
|
||||||
|
import { OverrideConfig } from 'component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm/VariantOverrides/VariantOverrides';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormControlLabel,
|
||||||
|
IconButton,
|
||||||
|
InputAdornment,
|
||||||
|
styled,
|
||||||
|
Switch,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { IPayload } from 'interfaces/featureToggle';
|
||||||
|
import { useOverrides } from 'component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm/VariantOverrides/useOverrides';
|
||||||
|
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
|
||||||
|
import { WeightType } from 'constants/variantTypes';
|
||||||
|
import { IFeatureVariantEdit } from '../EnvironmentVariantsModal';
|
||||||
|
import { Operation } from 'fast-json-patch';
|
||||||
|
import { Delete } from '@mui/icons-material';
|
||||||
|
|
||||||
|
const StyledVariantForm = styled('div')(({ theme }) => ({
|
||||||
|
position: 'relative',
|
||||||
|
backgroundColor: theme.palette.neutral.light,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
padding: theme.spacing(3),
|
||||||
|
marginBottom: theme.spacing(3),
|
||||||
|
borderRadius: theme.shape.borderRadiusLarge,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledDeleteButton = styled(IconButton)(({ theme }) => ({
|
||||||
|
position: 'absolute',
|
||||||
|
top: theme.spacing(2),
|
||||||
|
right: theme.spacing(2),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledLabel = styled('p')(({ theme }) => ({
|
||||||
|
fontSize: theme.fontSizes.smallBody,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledMarginLabel = styled(StyledLabel)(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
marginTop: theme.spacing(1),
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledSubLabel = styled('p')(({ theme }) => ({
|
||||||
|
fontSize: theme.fontSizes.smallBody,
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledFormControlLabel = styled(FormControlLabel)(({ theme }) => ({
|
||||||
|
marginBottom: theme.spacing(1),
|
||||||
|
'& > span': {
|
||||||
|
fontSize: theme.fontSizes.smallBody,
|
||||||
|
},
|
||||||
|
[theme.breakpoints.down('sm')]: {
|
||||||
|
marginTop: theme.spacing(1),
|
||||||
|
marginBottom: theme.spacing(1.5),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledInput = styled(Input)(() => ({
|
||||||
|
width: '100%',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledPercentageContainer = styled('div')(({ theme }) => ({
|
||||||
|
marginLeft: theme.spacing(3),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledWeightInput = styled(Input)(({ theme }) => ({
|
||||||
|
width: theme.spacing(24),
|
||||||
|
[theme.breakpoints.down('sm')]: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledNameContainer = styled('div')(({ theme }) => ({
|
||||||
|
marginTop: theme.spacing(3),
|
||||||
|
flexGrow: 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
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 StyledTopRow = styled(StyledRow)({
|
||||||
|
alignItems: 'end',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
});
|
||||||
|
|
||||||
|
const StyledSelectMenu = styled(SelectMenu)(({ theme }) => ({
|
||||||
|
minWidth: theme.spacing(20),
|
||||||
|
marginRight: theme.spacing(10),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledAddOverrideButton = styled(Button)(({ theme }) => ({
|
||||||
|
width: theme.spacing(20),
|
||||||
|
maxWidth: '100%',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const payloadOptions = [
|
||||||
|
{ key: 'string', label: 'string' },
|
||||||
|
{ key: 'json', label: 'json' },
|
||||||
|
{ key: 'csv', label: 'csv' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMPTY_PAYLOAD = { type: 'string', value: '' };
|
||||||
|
|
||||||
|
enum ErrorField {
|
||||||
|
NAME = 'name',
|
||||||
|
PERCENTAGE = 'percentage',
|
||||||
|
PAYLOAD = 'payload',
|
||||||
|
OTHER = 'other',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IVariantFormErrors {
|
||||||
|
[ErrorField.NAME]?: string;
|
||||||
|
[ErrorField.PERCENTAGE]?: string;
|
||||||
|
[ErrorField.PAYLOAD]?: string;
|
||||||
|
[ErrorField.OTHER]?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IVariantFormProps {
|
||||||
|
variant: IFeatureVariantEdit;
|
||||||
|
variants: IFeatureVariantEdit[];
|
||||||
|
updateVariant: (updatedVariant: IFeatureVariantEdit) => void;
|
||||||
|
removeVariant: (variantId: string) => void;
|
||||||
|
projectId: string;
|
||||||
|
apiPayload: {
|
||||||
|
patch: Operation[];
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VariantForm = ({
|
||||||
|
variant,
|
||||||
|
variants,
|
||||||
|
updateVariant,
|
||||||
|
removeVariant,
|
||||||
|
apiPayload,
|
||||||
|
}: IVariantFormProps) => {
|
||||||
|
const [name, setName] = useState(variant.name);
|
||||||
|
const [customPercentage, setCustomPercentage] = useState(
|
||||||
|
variant.weightType === WeightType.FIX
|
||||||
|
);
|
||||||
|
const [percentage, setPercentage] = useState(String(variant.weight / 10));
|
||||||
|
const [payload, setPayload] = useState<IPayload>(
|
||||||
|
variant.payload || EMPTY_PAYLOAD
|
||||||
|
);
|
||||||
|
const [overrides, overridesDispatch] = useOverrides(
|
||||||
|
variant.overrides || []
|
||||||
|
);
|
||||||
|
const { context } = useUnleashContext();
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<IVariantFormErrors>({});
|
||||||
|
|
||||||
|
const clearError = (field: ErrorField) => {
|
||||||
|
setErrors(errors => ({ ...errors, [field]: undefined }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setError = (field: ErrorField, error: string) => {
|
||||||
|
setErrors(errors => ({ ...errors, [field]: error }));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clearError(ErrorField.PERCENTAGE);
|
||||||
|
if (apiPayload.error?.includes('%')) {
|
||||||
|
setError(ErrorField.PERCENTAGE, 'Total weight must equal 100%');
|
||||||
|
}
|
||||||
|
}, [apiPayload.error]);
|
||||||
|
|
||||||
|
const editing = !variant.new;
|
||||||
|
const customPercentageVisible =
|
||||||
|
variants.filter(
|
||||||
|
({ id, weightType }) =>
|
||||||
|
id !== variant.id && weightType === WeightType.VARIABLE
|
||||||
|
).length > 0;
|
||||||
|
|
||||||
|
const isProtectedVariant = (variant: IFeatureVariantEdit): boolean => {
|
||||||
|
const isVariable = variant.weightType === WeightType.VARIABLE;
|
||||||
|
|
||||||
|
const atLeastOneFixedVariant = variants.some(variant => {
|
||||||
|
return variant.weightType === WeightType.FIX;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasOnlyOneVariableVariant =
|
||||||
|
variants.filter(variant => {
|
||||||
|
return variant.weightType === WeightType.VARIABLE;
|
||||||
|
}).length == 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
atLeastOneFixedVariant && hasOnlyOneVariableVariant && isVariable
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSetName = (name: string) => {
|
||||||
|
clearError(ErrorField.NAME);
|
||||||
|
if (!isNameUnique(name, variant.id)) {
|
||||||
|
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: [] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isNameNotEmpty = (name: string) => Boolean(name.length);
|
||||||
|
const isNameUnique = (name: string, id: string) =>
|
||||||
|
editing ||
|
||||||
|
!variants.some(variant => variant.name === name && variant.id !== id);
|
||||||
|
const isValidPercentage = (percentage: string) => {
|
||||||
|
if (!customPercentage) return true;
|
||||||
|
if (percentage === '') return false;
|
||||||
|
if (percentage.match(/\.[0-9]{2,}$/)) 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateVariant({
|
||||||
|
...variant,
|
||||||
|
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),
|
||||||
|
isValid:
|
||||||
|
isNameNotEmpty(name) &&
|
||||||
|
isNameUnique(name, variant.id) &&
|
||||||
|
isValidPercentage(percentage) &&
|
||||||
|
isValidPayload(payload) &&
|
||||||
|
!apiPayload.error,
|
||||||
|
});
|
||||||
|
}, [name, customPercentage, percentage, payload, overrides]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!customPercentage) {
|
||||||
|
setPercentage(String(variant.weight / 10));
|
||||||
|
}
|
||||||
|
}, [variant.weight]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledVariantForm>
|
||||||
|
<StyledDeleteButton
|
||||||
|
onClick={() => removeVariant(variant.id)}
|
||||||
|
disabled={isProtectedVariant(variant)}
|
||||||
|
>
|
||||||
|
<Delete />
|
||||||
|
</StyledDeleteButton>
|
||||||
|
<StyledTopRow>
|
||||||
|
<StyledNameContainer>
|
||||||
|
<StyledLabel>Variant name</StyledLabel>
|
||||||
|
<StyledSubLabel>
|
||||||
|
This will be used to identify the variant in your code
|
||||||
|
</StyledSubLabel>
|
||||||
|
<StyledInput
|
||||||
|
autoFocus
|
||||||
|
label="Variant name"
|
||||||
|
error={Boolean(errors.name)}
|
||||||
|
errorText={errors.name}
|
||||||
|
value={name}
|
||||||
|
onChange={e => onSetName(e.target.value)}
|
||||||
|
disabled={editing}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</StyledNameContainer>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={customPercentageVisible}
|
||||||
|
show={
|
||||||
|
<StyledPercentageContainer>
|
||||||
|
<StyledFormControlLabel
|
||||||
|
label="Custom percentage"
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={customPercentage}
|
||||||
|
onChange={e =>
|
||||||
|
setCustomPercentage(
|
||||||
|
e.target.checked
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StyledWeightInput
|
||||||
|
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>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</StyledPercentageContainer>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</StyledTopRow>
|
||||||
|
<StyledMarginLabel>
|
||||||
|
Payload
|
||||||
|
<HelpIcon tooltip="Passed along with the the variant object." />
|
||||||
|
</StyledMarginLabel>
|
||||||
|
<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>
|
||||||
|
<StyledMarginLabel>
|
||||||
|
Overrides
|
||||||
|
<HelpIcon tooltip="Here you can specify which users should get this variant." />
|
||||||
|
</StyledMarginLabel>
|
||||||
|
<OverrideConfig
|
||||||
|
overrides={overrides}
|
||||||
|
overridesDispatch={overridesDispatch}
|
||||||
|
/>
|
||||||
|
<StyledAddOverrideButton
|
||||||
|
onClick={onAddOverride}
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
Add override
|
||||||
|
</StyledAddOverrideButton>
|
||||||
|
</StyledVariantForm>
|
||||||
|
);
|
||||||
|
};
|
@ -15,9 +15,8 @@ import {
|
|||||||
IFeatureVariant,
|
IFeatureVariant,
|
||||||
} from 'interfaces/featureToggle';
|
} from 'interfaces/featureToggle';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { EnvironmentVariantModal } from './EnvironmentVariantModal/EnvironmentVariantModal';
|
import { EnvironmentVariantsModal } from './EnvironmentVariantsModal/EnvironmentVariantsModal';
|
||||||
import { EnvironmentVariantsCard } from './EnvironmentVariantsCard/EnvironmentVariantsCard';
|
import { EnvironmentVariantsCard } from './EnvironmentVariantsCard/EnvironmentVariantsCard';
|
||||||
import { VariantDeleteDialog } from './VariantDeleteDialog/VariantDeleteDialog';
|
|
||||||
import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
|
import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
|
||||||
import { formatUnknownError } from 'utils/formatUnknownError';
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
import useToast from 'hooks/useToast';
|
import useToast from 'hooks/useToast';
|
||||||
@ -27,6 +26,8 @@ import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useCh
|
|||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
|
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
|
||||||
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
|
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
|
||||||
|
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
|
||||||
|
import { Edit } from '@mui/icons-material';
|
||||||
|
|
||||||
const StyledAlert = styled(Alert)(({ theme }) => ({
|
const StyledAlert = styled(Alert)(({ theme }) => ({
|
||||||
marginBottom: theme.spacing(4),
|
marginBottom: theme.spacing(4),
|
||||||
@ -62,9 +63,7 @@ export const FeatureEnvironmentVariants = () => {
|
|||||||
const [searchValue, setSearchValue] = useState('');
|
const [searchValue, setSearchValue] = useState('');
|
||||||
const [selectedEnvironment, setSelectedEnvironment] =
|
const [selectedEnvironment, setSelectedEnvironment] =
|
||||||
useState<IFeatureEnvironmentWithCrEnabled>();
|
useState<IFeatureEnvironmentWithCrEnabled>();
|
||||||
const [selectedVariant, setSelectedVariant] = useState<IFeatureVariant>();
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
||||||
|
|
||||||
const environments: IFeatureEnvironmentWithCrEnabled[] = useMemo(
|
const environments: IFeatureEnvironmentWithCrEnabled[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -91,8 +90,12 @@ export const FeatureEnvironmentVariants = () => {
|
|||||||
patch: jsonpatch.Operation[];
|
patch: jsonpatch.Operation[];
|
||||||
error?: string;
|
error?: string;
|
||||||
} => {
|
} => {
|
||||||
const updatedNewVariants = updateWeight(newVariants, 1000);
|
try {
|
||||||
return { patch: createPatch(variants, updatedNewVariants) };
|
const updatedNewVariants = updateWeight(newVariants, 1000);
|
||||||
|
return { patch: createPatch(variants, updatedNewVariants) };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { patch: [], error: formatUnknownError(error) };
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCrPayload = (variants: IFeatureVariant[]) => ({
|
const getCrPayload = (variants: IFeatureVariant[]) => ({
|
||||||
@ -114,10 +117,21 @@ export const FeatureEnvironmentVariants = () => {
|
|||||||
refetchChangeRequests();
|
refetchChangeRequests();
|
||||||
} else {
|
} else {
|
||||||
const environmentVariants = environment.variants ?? [];
|
const environmentVariants = environment.variants ?? [];
|
||||||
const { patch } = getApiPayload(environmentVariants, variants);
|
const { patch, error } = getApiPayload(
|
||||||
|
environmentVariants,
|
||||||
|
variants
|
||||||
|
);
|
||||||
|
|
||||||
if (patch.length === 0) return;
|
if (patch.length === 0) return;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setToastData({
|
||||||
|
type: 'error',
|
||||||
|
title: error,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await patchFeatureEnvironmentVariants(
|
await patchFeatureEnvironmentVariants(
|
||||||
projectId,
|
projectId,
|
||||||
featureId,
|
featureId,
|
||||||
@ -187,66 +201,20 @@ export const FeatureEnvironmentVariants = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const addVariant = (environment: IFeatureEnvironmentWithCrEnabled) => {
|
const editVariants = (environment: IFeatureEnvironmentWithCrEnabled) => {
|
||||||
setSelectedEnvironment(environment);
|
setSelectedEnvironment(environment);
|
||||||
setSelectedVariant(undefined);
|
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const editVariant = (
|
const onVariantsConfirm = async (updatedVariants: IFeatureVariant[]) => {
|
||||||
environment: IFeatureEnvironmentWithCrEnabled,
|
|
||||||
variant: IFeatureVariant
|
|
||||||
) => {
|
|
||||||
setSelectedEnvironment(environment);
|
|
||||||
setSelectedVariant(variant);
|
|
||||||
setModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteVariant = (
|
|
||||||
environment: IFeatureEnvironmentWithCrEnabled,
|
|
||||||
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: selectedEnvironment.crEnabled
|
|
||||||
? 'Variant deletion added to draft'
|
|
||||||
: 'Variant deleted successfully',
|
|
||||||
type: 'success',
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
setToastApiError(formatUnknownError(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onVariantConfirm = async (updatedVariants: IFeatureVariant[]) => {
|
|
||||||
if (selectedEnvironment) {
|
if (selectedEnvironment) {
|
||||||
try {
|
try {
|
||||||
await updateVariants(selectedEnvironment, updatedVariants);
|
await updateVariants(selectedEnvironment, updatedVariants);
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
setToastData({
|
setToastData({
|
||||||
title: selectedEnvironment.crEnabled
|
title: selectedEnvironment.crEnabled
|
||||||
? `Variant ${
|
? `Variant changes added to draft`
|
||||||
selectedVariant ? 'changes' : ''
|
: 'Variants updated successfully',
|
||||||
} added to draft`
|
|
||||||
: `Variant ${
|
|
||||||
selectedVariant ? 'updated' : 'added'
|
|
||||||
} successfully`,
|
|
||||||
type: 'success',
|
type: 'success',
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@ -273,23 +241,6 @@ export const FeatureEnvironmentVariants = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onUpdateStickiness = async (
|
|
||||||
environment: IFeatureEnvironmentWithCrEnabled,
|
|
||||||
updatedVariants: IFeatureVariant[]
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
await updateVariants(environment, updatedVariants);
|
|
||||||
setToastData({
|
|
||||||
title: environment.crEnabled
|
|
||||||
? 'Variant stickiness update added to draft'
|
|
||||||
: 'Variant stickiness updated successfully',
|
|
||||||
type: 'success',
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
setToastApiError(formatUnknownError(error));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContent
|
<PageContent
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
@ -339,15 +290,6 @@ export const FeatureEnvironmentVariants = () => {
|
|||||||
key={environment.name}
|
key={environment.name}
|
||||||
environment={environment}
|
environment={environment}
|
||||||
searchValue={searchValue}
|
searchValue={searchValue}
|
||||||
onEditVariant={(variant: IFeatureVariant) =>
|
|
||||||
editVariant(environment, variant)
|
|
||||||
}
|
|
||||||
onDeleteVariant={(variant: IFeatureVariant) =>
|
|
||||||
deleteVariant(environment, variant)
|
|
||||||
}
|
|
||||||
onUpdateStickiness={(variants: IFeatureVariant[]) =>
|
|
||||||
onUpdateStickiness(environment, variants)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<StyledButtonContainer>
|
<StyledButtonContainer>
|
||||||
<PushVariantsButton
|
<PushVariantsButton
|
||||||
@ -370,33 +312,51 @@ export const FeatureEnvironmentVariants = () => {
|
|||||||
onCopyVariantsFrom={onCopyVariantsFrom}
|
onCopyVariantsFrom={onCopyVariantsFrom}
|
||||||
otherEnvsWithVariants={otherEnvsWithVariants}
|
otherEnvsWithVariants={otherEnvsWithVariants}
|
||||||
/>
|
/>
|
||||||
<PermissionButton
|
<ConditionallyRender
|
||||||
onClick={() => addVariant(environment)}
|
condition={Boolean(
|
||||||
variant="outlined"
|
environment.variants?.length
|
||||||
permission={UPDATE_FEATURE_ENVIRONMENT_VARIANTS}
|
)}
|
||||||
projectId={projectId}
|
show={
|
||||||
environmentId={environment.name}
|
<PermissionIconButton
|
||||||
>
|
onClick={() =>
|
||||||
Add variant
|
editVariants(environment)
|
||||||
</PermissionButton>
|
}
|
||||||
|
permission={
|
||||||
|
UPDATE_FEATURE_ENVIRONMENT_VARIANTS
|
||||||
|
}
|
||||||
|
projectId={projectId}
|
||||||
|
environmentId={environment.name}
|
||||||
|
>
|
||||||
|
<Edit />
|
||||||
|
</PermissionIconButton>
|
||||||
|
}
|
||||||
|
elseShow={
|
||||||
|
<PermissionButton
|
||||||
|
onClick={() =>
|
||||||
|
editVariants(environment)
|
||||||
|
}
|
||||||
|
variant="outlined"
|
||||||
|
permission={
|
||||||
|
UPDATE_FEATURE_ENVIRONMENT_VARIANTS
|
||||||
|
}
|
||||||
|
projectId={projectId}
|
||||||
|
environmentId={environment.name}
|
||||||
|
>
|
||||||
|
Add variant
|
||||||
|
</PermissionButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</StyledButtonContainer>
|
</StyledButtonContainer>
|
||||||
</EnvironmentVariantsCard>
|
</EnvironmentVariantsCard>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<EnvironmentVariantModal
|
<EnvironmentVariantsModal
|
||||||
environment={selectedEnvironment}
|
environment={selectedEnvironment}
|
||||||
variant={selectedVariant}
|
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
setOpen={setModalOpen}
|
setOpen={setModalOpen}
|
||||||
getApiPayload={getApiPayload}
|
getApiPayload={getApiPayload}
|
||||||
getCrPayload={getCrPayload}
|
getCrPayload={getCrPayload}
|
||||||
onConfirm={onVariantConfirm}
|
onConfirm={onVariantsConfirm}
|
||||||
/>
|
|
||||||
<VariantDeleteDialog
|
|
||||||
variant={selectedVariant}
|
|
||||||
open={deleteOpen}
|
|
||||||
setOpen={setDeleteOpen}
|
|
||||||
onConfirm={onDeleteConfirm}
|
|
||||||
/>
|
/>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
);
|
);
|
||||||
|
@ -1,42 +0,0 @@
|
|||||||
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="Cancel"
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -2607,6 +2607,11 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@types/urijs/-/urijs-1.19.19.tgz#2789369799907fc11e2bc6e3a00f6478c2281b95"
|
resolved "https://registry.yarnpkg.com/@types/urijs/-/urijs-1.19.19.tgz#2789369799907fc11e2bc6e3a00f6478c2281b95"
|
||||||
integrity sha512-FDJNkyhmKLw7uEvTxx5tSXfPeQpO0iy73Ry+PmYZJvQy0QIWX8a7kJ4kLWRf+EbTPJEPDSgPXHaM7pzr5lmvCg==
|
integrity sha512-FDJNkyhmKLw7uEvTxx5tSXfPeQpO0iy73Ry+PmYZJvQy0QIWX8a7kJ4kLWRf+EbTPJEPDSgPXHaM7pzr5lmvCg==
|
||||||
|
|
||||||
|
"@types/uuid@^9.0.0":
|
||||||
|
version "9.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.0.tgz#53ef263e5239728b56096b0a869595135b7952d2"
|
||||||
|
integrity sha512-kr90f+ERiQtKWMz5rP32ltJ/BtULDI5RVO0uavn1HQUOwjx0R1h0rnDYNL0CepF1zL5bSY6FISAfd9tOdDhU5Q==
|
||||||
|
|
||||||
"@types/yargs-parser@*":
|
"@types/yargs-parser@*":
|
||||||
version "21.0.0"
|
version "21.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b"
|
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b"
|
||||||
|
Loading…
Reference in New Issue
Block a user