1
0
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:
Nuno Góis 2023-01-27 08:13:57 +00:00 committed by GitHub
parent f1984080a9
commit 816c8dbb46
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 964 additions and 864 deletions

View File

@ -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",

View File

@ -4,5 +4,9 @@ export const useStyles = makeStyles()(theme => ({
helperText: {
position: 'absolute',
bottom: '-1rem',
whiteSpace: 'nowrap',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
}));

View File

@ -48,6 +48,7 @@ const Input = ({
onChange={onChange}
FormHelperTextProps={{
['data-testid']: INPUT_ERROR_TEXT,
title: errorText,
classes: {
root: styles.helperText,
},

View File

@ -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',

View File

@ -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>
);
};

View File

@ -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}
/>
</>
}
/>

View File

@ -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 {

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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;
} => {
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}
/>
<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={() => addVariant(environment)}
onClick={() =>
editVariants(environment)
}
variant="outlined"
permission={UPDATE_FEATURE_ENVIRONMENT_VARIANTS}
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>
);

View File

@ -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>
);
};

View File

@ -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"