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-timeago": "4.1.3",
|
||||
"@types/semver": "7.3.13",
|
||||
"@types/uuid": "^9.0.0",
|
||||
"@uiw/codemirror-theme-duotone": "4.19.6",
|
||||
"@uiw/react-codemirror": "4.19.6",
|
||||
"@vitejs/plugin-react": "3.0.1",
|
||||
|
@ -4,5 +4,9 @@ export const useStyles = makeStyles()(theme => ({
|
||||
helperText: {
|
||||
position: 'absolute',
|
||||
bottom: '-1rem',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
}));
|
||||
|
@ -48,6 +48,7 @@ const Input = ({
|
||||
onChange={onChange}
|
||||
FormHelperTextProps={{
|
||||
['data-testid']: INPUT_ERROR_TEXT,
|
||||
title: errorText,
|
||||
classes: {
|
||||
root: styles.helperText,
|
||||
},
|
||||
|
@ -3,6 +3,7 @@ import { IUiConfig } from 'interfaces/uiConfig';
|
||||
import { INavigationMenuItem } from 'interfaces/route';
|
||||
import { IFeatureVariant } from 'interfaces/featureToggle';
|
||||
import { format, isValid } from 'date-fns';
|
||||
import { IFeatureVariantEdit } from 'component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/EnvironmentVariantsModal';
|
||||
|
||||
export const filterByConfig =
|
||||
(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 = {
|
||||
overlay: {
|
||||
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 { styled } from '@mui/material';
|
||||
import { IFeatureEnvironment, IFeatureVariant } from 'interfaces/featureToggle';
|
||||
import { IFeatureEnvironment } 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';
|
||||
import { Badge } from 'component/common/Badge/Badge';
|
||||
|
||||
const StyledCard = styled('div')(({ theme }) => ({
|
||||
padding: theme.spacing(3),
|
||||
@ -16,7 +14,7 @@ const StyledCard = styled('div')(({ theme }) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledHeader = styled('div')(() => ({
|
||||
const StyledHeader = styled('div')({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
@ -24,7 +22,7 @@ const StyledHeader = styled('div')(() => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
const StyledCloudCircle = styled(CloudCircle, {
|
||||
shouldForwardProp: prop => prop !== 'deprecated',
|
||||
@ -41,6 +39,7 @@ const StyledName = styled('span', {
|
||||
? theme.palette.text.secondary
|
||||
: theme.palette.text.primary,
|
||||
marginLeft: theme.spacing(1.25),
|
||||
fontWeight: theme.fontWeight.bold,
|
||||
}));
|
||||
|
||||
const StyledDescription = styled('p')(({ theme }) => ({
|
||||
@ -49,57 +48,27 @@ const StyledDescription = styled('p')(({ theme }) => ({
|
||||
marginBottom: theme.spacing(1.5),
|
||||
}));
|
||||
|
||||
const StyledGeneralSelect = styled(GeneralSelect)(({ theme }) => ({
|
||||
minWidth: theme.spacing(20),
|
||||
const StyledStickinessContainer = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1.5),
|
||||
marginBottom: theme.spacing(0.5),
|
||||
}));
|
||||
|
||||
interface IEnvironmentVariantsCardProps {
|
||||
environment: IFeatureEnvironment;
|
||||
searchValue: string;
|
||||
onEditVariant: (variant: IFeatureVariant) => void;
|
||||
onDeleteVariant: (variant: IFeatureVariant) => void;
|
||||
onUpdateStickiness: (variant: IFeatureVariant[]) => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const EnvironmentVariantsCard = ({
|
||||
environment,
|
||||
searchValue,
|
||||
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>
|
||||
@ -118,14 +87,15 @@ export const EnvironmentVariantsCard = ({
|
||||
<EnvironmentVariantsTable
|
||||
environment={environment}
|
||||
searchValue={searchValue}
|
||||
onEditVariant={onEditVariant}
|
||||
onDeleteVariant={onDeleteVariant}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={variants.length > 1}
|
||||
show={
|
||||
<>
|
||||
<p>Stickiness</p>
|
||||
<StyledStickinessContainer>
|
||||
<p>Stickiness:</p>
|
||||
<Badge>{stickiness}</Badge>
|
||||
</StyledStickinessContainer>
|
||||
<StyledDescription>
|
||||
By overriding the stickiness you can
|
||||
control which parameter is used to
|
||||
@ -139,11 +109,6 @@ export const EnvironmentVariantsCard = ({
|
||||
Read more
|
||||
</a>
|
||||
</StyledDescription>
|
||||
<StyledGeneralSelect
|
||||
options={options}
|
||||
value={stickiness}
|
||||
onChange={onStickinessChange}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
@ -20,7 +20,6 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||
import { useSearch } from 'hooks/useSearch';
|
||||
import {
|
||||
IFeatureEnvironment,
|
||||
IFeatureVariant,
|
||||
IOverride,
|
||||
IPayload,
|
||||
} from 'interfaces/featureToggle';
|
||||
@ -29,9 +28,7 @@ import { 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';
|
||||
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
|
||||
import { WeightType } from 'constants/variantTypes';
|
||||
|
||||
const StyledTableContainer = styled('div')(({ theme }) => ({
|
||||
margin: theme.spacing(3, 0),
|
||||
@ -40,15 +37,11 @@ const StyledTableContainer = styled('div')(({ theme }) => ({
|
||||
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');
|
||||
|
||||
@ -108,30 +101,11 @@ export const EnvironmentVariantsTable = ({
|
||||
},
|
||||
{
|
||||
Header: 'Type',
|
||||
accessor: 'weightType',
|
||||
accessor: (row: any) =>
|
||||
row.weightType === 'fix' ? 'Fixed' : 'Variable',
|
||||
Cell: TextCell,
|
||||
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]
|
||||
);
|
||||
@ -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 {
|
||||
|
@ -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,
|
||||
} from 'interfaces/featureToggle';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { EnvironmentVariantModal } from './EnvironmentVariantModal/EnvironmentVariantModal';
|
||||
import { EnvironmentVariantsModal } from './EnvironmentVariantsModal/EnvironmentVariantsModal';
|
||||
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';
|
||||
@ -27,6 +26,8 @@ import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useCh
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
|
||||
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 }) => ({
|
||||
marginBottom: theme.spacing(4),
|
||||
@ -62,9 +63,7 @@ export const FeatureEnvironmentVariants = () => {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [selectedEnvironment, setSelectedEnvironment] =
|
||||
useState<IFeatureEnvironmentWithCrEnabled>();
|
||||
const [selectedVariant, setSelectedVariant] = useState<IFeatureVariant>();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
const environments: IFeatureEnvironmentWithCrEnabled[] = useMemo(
|
||||
() =>
|
||||
@ -91,8 +90,12 @@ export const FeatureEnvironmentVariants = () => {
|
||||
patch: jsonpatch.Operation[];
|
||||
error?: string;
|
||||
} => {
|
||||
const updatedNewVariants = updateWeight(newVariants, 1000);
|
||||
return { patch: createPatch(variants, updatedNewVariants) };
|
||||
try {
|
||||
const updatedNewVariants = updateWeight(newVariants, 1000);
|
||||
return { patch: createPatch(variants, updatedNewVariants) };
|
||||
} catch (error: unknown) {
|
||||
return { patch: [], error: formatUnknownError(error) };
|
||||
}
|
||||
};
|
||||
|
||||
const getCrPayload = (variants: IFeatureVariant[]) => ({
|
||||
@ -114,10 +117,21 @@ export const FeatureEnvironmentVariants = () => {
|
||||
refetchChangeRequests();
|
||||
} else {
|
||||
const environmentVariants = environment.variants ?? [];
|
||||
const { patch } = getApiPayload(environmentVariants, variants);
|
||||
const { patch, error } = getApiPayload(
|
||||
environmentVariants,
|
||||
variants
|
||||
);
|
||||
|
||||
if (patch.length === 0) return;
|
||||
|
||||
if (error) {
|
||||
setToastData({
|
||||
type: 'error',
|
||||
title: error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await patchFeatureEnvironmentVariants(
|
||||
projectId,
|
||||
featureId,
|
||||
@ -187,66 +201,20 @@ export const FeatureEnvironmentVariants = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const addVariant = (environment: IFeatureEnvironmentWithCrEnabled) => {
|
||||
const editVariants = (environment: IFeatureEnvironmentWithCrEnabled) => {
|
||||
setSelectedEnvironment(environment);
|
||||
setSelectedVariant(undefined);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const editVariant = (
|
||||
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[]) => {
|
||||
const onVariantsConfirm = async (updatedVariants: IFeatureVariant[]) => {
|
||||
if (selectedEnvironment) {
|
||||
try {
|
||||
await updateVariants(selectedEnvironment, updatedVariants);
|
||||
setModalOpen(false);
|
||||
setToastData({
|
||||
title: selectedEnvironment.crEnabled
|
||||
? `Variant ${
|
||||
selectedVariant ? 'changes' : ''
|
||||
} added to draft`
|
||||
: `Variant ${
|
||||
selectedVariant ? 'updated' : 'added'
|
||||
} successfully`,
|
||||
? `Variant changes added to draft`
|
||||
: 'Variants updated successfully',
|
||||
type: 'success',
|
||||
});
|
||||
} 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 (
|
||||
<PageContent
|
||||
isLoading={loading}
|
||||
@ -339,15 +290,6 @@ export const FeatureEnvironmentVariants = () => {
|
||||
key={environment.name}
|
||||
environment={environment}
|
||||
searchValue={searchValue}
|
||||
onEditVariant={(variant: IFeatureVariant) =>
|
||||
editVariant(environment, variant)
|
||||
}
|
||||
onDeleteVariant={(variant: IFeatureVariant) =>
|
||||
deleteVariant(environment, variant)
|
||||
}
|
||||
onUpdateStickiness={(variants: IFeatureVariant[]) =>
|
||||
onUpdateStickiness(environment, variants)
|
||||
}
|
||||
>
|
||||
<StyledButtonContainer>
|
||||
<PushVariantsButton
|
||||
@ -370,33 +312,51 @@ export const FeatureEnvironmentVariants = () => {
|
||||
onCopyVariantsFrom={onCopyVariantsFrom}
|
||||
otherEnvsWithVariants={otherEnvsWithVariants}
|
||||
/>
|
||||
<PermissionButton
|
||||
onClick={() => addVariant(environment)}
|
||||
variant="outlined"
|
||||
permission={UPDATE_FEATURE_ENVIRONMENT_VARIANTS}
|
||||
projectId={projectId}
|
||||
environmentId={environment.name}
|
||||
>
|
||||
Add variant
|
||||
</PermissionButton>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(
|
||||
environment.variants?.length
|
||||
)}
|
||||
show={
|
||||
<PermissionIconButton
|
||||
onClick={() =>
|
||||
editVariants(environment)
|
||||
}
|
||||
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>
|
||||
</EnvironmentVariantsCard>
|
||||
);
|
||||
})}
|
||||
<EnvironmentVariantModal
|
||||
<EnvironmentVariantsModal
|
||||
environment={selectedEnvironment}
|
||||
variant={selectedVariant}
|
||||
open={modalOpen}
|
||||
setOpen={setModalOpen}
|
||||
getApiPayload={getApiPayload}
|
||||
getCrPayload={getCrPayload}
|
||||
onConfirm={onVariantConfirm}
|
||||
/>
|
||||
<VariantDeleteDialog
|
||||
variant={selectedVariant}
|
||||
open={deleteOpen}
|
||||
setOpen={setDeleteOpen}
|
||||
onConfirm={onDeleteConfirm}
|
||||
onConfirm={onVariantsConfirm}
|
||||
/>
|
||||
</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"
|
||||
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@*":
|
||||
version "21.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b"
|
||||
|
Loading…
Reference in New Issue
Block a user