1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-29 01:15:48 +02:00

refactor: remove old components after variants per env (#3110)

https://linear.app/unleash/issue/2-427/clean-up-previous-components-from-the-old-ui

Major clean up after we fully migrated to variants per environment,
removing old components.
You can read more about it in the original PR:
https://github.com/Unleash/unleash/pull/2453
This commit is contained in:
Nuno Góis 2023-02-14 16:03:53 +00:00 committed by GitHub
parent 6c72efb300
commit f30a8a66b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 9 additions and 1813 deletions

View File

@ -1,4 +1,4 @@
import { weightTypes } from '../feature/FeatureView/FeatureVariants/FeatureVariantsList/AddFeatureVariant/enums';
import { weightTypes } from 'constants/weightTypes';
import { IUiConfig } from 'interfaces/uiConfig';
import { INavigationMenuItem } from 'interfaces/route';
import { IFeatureVariant } from 'interfaces/featureToggle';

View File

@ -1,160 +0,0 @@
import { ENVIRONMENT_STRATEGY_ERROR } from 'constants/apiErrors';
import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import useToast from 'hooks/useToast';
import { IFeatureEnvironment } from 'interfaces/featureToggle';
import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { UPDATE_FEATURE_ENVIRONMENT } from 'component/providers/AccessProvider/permissions';
import React from 'react';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useChangeRequestToggle } from 'hooks/useChangeRequestToggle';
import { ChangeRequestDialogue } from 'component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestConfirmDialog';
import { UpdateEnabledMessage } from 'component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestMessages/UpdateEnabledMessage';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { styled } from '@mui/material';
import { FeatureOverviewSidePanelEnvironmentHider } from '../../FeatureOverviewSidePanel/FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitch/FeatureOverviewSidePanelEnvironmentHider';
interface IFeatureOverviewEnvSwitchProps {
env: IFeatureEnvironment;
callback?: () => void;
text?: string;
showInfoBox: () => void;
hiddenEnvironments: Set<String>;
setHiddenEnvironments: (environment: string) => void;
}
const StyledContainer = styled('div')({
display: 'flex',
alignItems: 'center',
});
const StyledLabel = styled('label')({
display: 'inline-flex',
alignItems: 'center',
cursor: 'pointer',
});
const FeatureOverviewEnvSwitch = ({
env,
callback,
text,
showInfoBox,
hiddenEnvironments,
setHiddenEnvironments,
}: IFeatureOverviewEnvSwitchProps) => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { toggleFeatureEnvironmentOn, toggleFeatureEnvironmentOff } =
useFeatureApi();
const { refetchFeature } = useFeature(projectId, featureId);
const { setToastData, setToastApiError } = useToast();
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const {
onChangeRequestToggle,
onChangeRequestToggleClose,
onChangeRequestToggleConfirm,
changeRequestDialogDetails,
} = useChangeRequestToggle(projectId);
const handleToggleEnvironmentOn = async () => {
try {
await toggleFeatureEnvironmentOn(projectId, featureId, env.name);
setToastData({
type: 'success',
title: `Available in ${env.name}`,
text: `${featureId} is now available in ${env.name} based on its defined strategies.`,
});
refetchFeature();
if (callback) {
callback();
}
} catch (error: unknown) {
if (
error instanceof Error &&
error.message === ENVIRONMENT_STRATEGY_ERROR
) {
showInfoBox();
} else {
setToastApiError(formatUnknownError(error));
}
}
};
const handleToggleEnvironmentOff = async () => {
try {
await toggleFeatureEnvironmentOff(projectId, featureId, env.name);
setToastData({
type: 'success',
title: `Unavailable in ${env.name}`,
text: `${featureId} is unavailable in ${env.name} and its strategies will no longer have any effect.`,
});
refetchFeature();
if (callback) {
callback();
}
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
const toggleEnvironment = async (e: React.ChangeEvent) => {
if (isChangeRequestConfigured(env.name)) {
e.preventDefault();
onChangeRequestToggle(featureId, env.name, !env.enabled);
return;
}
if (env.enabled) {
await handleToggleEnvironmentOff();
return;
}
await handleToggleEnvironmentOn();
};
let content = text ? (
text
) : (
<>
{' '}
<span data-loading>{env.enabled ? 'enabled' : 'disabled'} in</span>
&nbsp;
<StringTruncator text={env.name} maxWidth="120" maxLength={15} />
</>
);
return (
<StyledContainer>
<StyledLabel>
<PermissionSwitch
permission={UPDATE_FEATURE_ENVIRONMENT}
projectId={projectId}
checked={env.enabled}
onChange={toggleEnvironment}
environmentId={env.name}
/>
{content}
</StyledLabel>
<FeatureOverviewSidePanelEnvironmentHider
environment={env}
hiddenEnvironments={hiddenEnvironments}
setHiddenEnvironments={setHiddenEnvironments}
/>
<ChangeRequestDialogue
isOpen={changeRequestDialogDetails.isOpen}
onClose={onChangeRequestToggleClose}
environment={changeRequestDialogDetails?.environment}
onConfirm={onChangeRequestToggleConfirm}
messageComponent={
<UpdateEnabledMessage
enabled={changeRequestDialogDetails?.enabled!}
featureName={changeRequestDialogDetails?.featureName!}
environment={changeRequestDialogDetails.environment!}
/>
}
/>
</StyledContainer>
);
};
export default FeatureOverviewEnvSwitch;

View File

@ -1,103 +0,0 @@
import { useState } from 'react';
import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import EnvironmentStrategyDialog from 'component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog';
import FeatureOverviewEnvSwitch from './FeatureOverviewEnvSwitch/FeatureOverviewEnvSwitch';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
import { styled } from '@mui/material';
const StyledContainer = styled('div')(({ theme }) => ({
borderRadius: theme.shape.borderRadiusLarge,
backgroundColor: theme.palette.background.paper,
display: 'flex',
flexDirection: 'column',
padding: '1.5rem',
maxWidth: '350px',
minWidth: '350px',
marginRight: '1rem',
marginTop: '1rem',
[theme.breakpoints.down(1000)]: {
marginBottom: '1rem',
width: '100%',
maxWidth: 'none',
minWidth: 'auto',
},
}));
const StyledHeader = styled('h3')(({ theme }) => ({
display: 'flex',
gap: theme.spacing(1),
alignItems: 'center',
fontSize: theme.fontSizes.bodySize,
fontWeight: 'normal',
margin: 0,
marginBottom: '0.5rem',
// Make the help icon align with the text.
'& > :last-child': {
position: 'relative',
top: 1,
},
}));
interface IFeatureOverviewEnvSwitchesProps {
hiddenEnvironments: Set<String>;
setHiddenEnvironments: (environment: string) => void;
}
const FeatureOverviewEnvSwitches = ({
hiddenEnvironments,
setHiddenEnvironments,
}: IFeatureOverviewEnvSwitchesProps) => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { feature } = useFeature(projectId, featureId);
useFeatureApi();
const [showInfoBox, setShowInfoBox] = useState(false);
const [environmentName, setEnvironmentName] = useState('');
const closeInfoBox = () => {
setShowInfoBox(false);
};
const renderEnvironmentSwitches = () => {
return feature?.environments.map(env => {
return (
<FeatureOverviewEnvSwitch
key={env.name}
env={env}
hiddenEnvironments={hiddenEnvironments}
setHiddenEnvironments={setHiddenEnvironments}
showInfoBox={() => {
setEnvironmentName(env.name);
setShowInfoBox(true);
}}
/>
);
});
};
return (
<StyledContainer data-testid="feature-toggle-status">
<StyledHeader data-loading>
Feature toggle status
<HelpIcon
tooltip="When a feature is switched off in an environment, it will always return false. When switched on, it will return true or false depending on its strategies."
placement="top"
/>
</StyledHeader>
{renderEnvironmentSwitches()}
<EnvironmentStrategyDialog
open={showInfoBox}
onClose={closeInfoBox}
projectId={projectId}
featureId={featureId}
environmentName={environmentName}
/>
</StyledContainer>
);
};
export default FeatureOverviewEnvSwitches;

View File

@ -6,10 +6,7 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
import { Edit } from '@mui/icons-material';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions';
import useFeatureTags from 'hooks/api/getters/useFeatureTags/useFeatureTags';
import FeatureOverviewTags from './FeatureOverviewTags/FeatureOverviewTags';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
const StyledContainer = styled('div')(({ theme }) => ({
borderRadius: theme.shape.borderRadiusLarge,
@ -31,11 +28,6 @@ const StyledPaddingContainerTop = styled('div')({
padding: '1.5rem 1.5rem 0 1.5rem',
});
const StyledPaddingContainerBottom = styled('div')(({ theme }) => ({
padding: '0 1.5rem 1.5rem 1.5rem',
borderTop: `1px solid ${theme.palette.divider}`,
}));
const StyledMetaDataHeader = styled('div')({
display: 'flex',
alignItems: 'center',
@ -66,10 +58,8 @@ const StyledDescriptionContainer = styled('div')(({ theme }) => ({
}));
const FeatureOverviewMetaData = () => {
const { uiConfig } = useUiConfig();
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { tags } = useFeatureTags(featureId);
const { feature } = useFeature(projectId, featureId);
const { project, description, type } = feature;

View File

@ -1,170 +0,0 @@
import React, { useContext, useState } from 'react';
import { Chip, styled } from '@mui/material';
import { Close, Label } from '@mui/icons-material';
import useFeatureTags from 'hooks/api/getters/useFeatureTags/useFeatureTags';
import slackIcon from 'assets/icons/slack.svg';
import jiraIcon from 'assets/icons/jira.svg';
import webhookIcon from 'assets/icons/webhooks.svg';
import { formatAssetPath } from 'utils/formatPath';
import useTagTypes from 'hooks/api/getters/useTagTypes/useTagTypes';
import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { ITag } from 'interfaces/tags';
import useToast from 'hooks/useToast';
import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import AccessContext from 'contexts/AccessContext';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
interface IFeatureOverviewTagsProps {
projectId: string;
}
const StyledContainer = styled('div')(({ theme }) => ({
borderRadius: theme.shape.borderRadiusLarge,
backgroundColor: theme.palette.primary.main,
display: 'flex',
flexDirection: 'column',
marginRight: theme.spacing(2),
marginTop: theme.spacing(2),
[theme.breakpoints.down(800)]: {
width: '100%',
maxWidth: 'none',
},
}));
const StyledTagChip = styled(Chip)(({ theme }) => ({
marginRight: theme.spacing(0.5),
marginTop: theme.spacing(1),
backgroundColor: theme.palette.text.tertiaryContrast,
fontSize: theme.fontSizes.smallBody,
}));
const StyledCloseIcon = styled(Close)(({ theme }) => ({
color: theme.palette.primary.light,
'&:hover': {
color: theme.palette.primary.light,
},
}));
const FeatureOverviewTags: React.FC<IFeatureOverviewTagsProps> = ({
projectId,
...rest
}) => {
const [showDelDialog, setShowDelDialog] = useState(false);
const [selectedTag, setSelectedTag] = useState<ITag>({
value: '',
type: '',
});
const featureId = useRequiredPathParam('featureId');
const { tags, refetch } = useFeatureTags(featureId);
const { tagTypes } = useTagTypes();
const { deleteTagFromFeature } = useFeatureApi();
const { setToastData, setToastApiError } = useToast();
const { hasAccess } = useContext(AccessContext);
const canDeleteTag = hasAccess(UPDATE_FEATURE, projectId);
const handleDelete = async () => {
try {
await deleteTagFromFeature(
featureId,
selectedTag.type,
selectedTag.value
);
refetch();
setToastData({
type: 'success',
title: 'Tag deleted',
text: 'Successfully deleted tag',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
const tagIcon = (typeName: string) => {
let tagType = tagTypes.find(type => type.name === typeName);
const style = { width: '20px', height: '20px', marginRight: '5px' };
if (tagType && tagType.icon) {
switch (tagType.name) {
case 'slack':
return (
<img
style={style}
alt="Slack"
src={formatAssetPath(slackIcon)}
/>
);
case 'jira':
return (
<img
style={style}
alt="JIRA"
src={formatAssetPath(jiraIcon)}
/>
);
case 'webhook':
return (
<img
style={style}
alt="Webhook"
src={formatAssetPath(webhookIcon)}
/>
);
default:
return <Label />;
}
} else {
return <span>{typeName[0].toUpperCase()}</span>;
}
};
const renderTag = (t: ITag) => (
<StyledTagChip
icon={tagIcon(t.type)}
data-loading
label={t.value}
key={`${t.type}:${t.value}`}
deleteIcon={<StyledCloseIcon titleAccess="Remove" />}
onDelete={
canDeleteTag
? () => {
setShowDelDialog(true);
setSelectedTag({ type: t.type, value: t.value });
}
: undefined
}
/>
);
return (
<StyledContainer {...rest}>
<Dialogue
open={showDelDialog}
onClose={() => {
setShowDelDialog(false);
setSelectedTag({ type: '', value: '' });
}}
onClick={() => {
setShowDelDialog(false);
handleDelete();
setSelectedTag({ type: '', value: '' });
}}
title="Are you sure you want to delete this tag?"
/>
<div>
<ConditionallyRender
condition={tags.length > 0}
show={tags.map(renderTag)}
elseShow={<p data-loading>No tags to display</p>}
/>
</div>
</StyledContainer>
);
};
export default FeatureOverviewTags;

View File

@ -327,6 +327,9 @@ export const FeatureEnvironmentVariants = () => {
}
projectId={projectId}
environmentId={environment.name}
tooltipProps={{
title: 'Edit variants',
}}
>
<Edit />
</PermissionIconButton>

View File

@ -1,9 +0,0 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
container: {
borderRadius: '12.5px',
backgroundColor: '#fff',
padding: '2rem',
},
}));

View File

@ -1,10 +0,0 @@
import { usePageTitle } from 'hooks/usePageTitle';
import { FeatureEnvironmentVariants } from './FeatureEnvironmentVariants/FeatureEnvironmentVariants';
const FeatureVariants = () => {
usePageTitle('Variants');
return <FeatureEnvironmentVariants />;
};
export default FeatureVariants;

View File

@ -1,400 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Button,
FormControl,
FormControlLabel,
Grid,
InputAdornment,
styled,
} from '@mui/material';
import { weightTypes } from './enums';
import { OverrideConfig } from './OverrideConfig/OverrideConfig';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { useThemeStyles } from 'themes/themeStyles';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { modalStyles, trim } from 'component/common/util';
import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch';
import { UPDATE_FEATURE_VARIANTS } from 'component/providers/AccessProvider/permissions';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { IFeatureVariant } from 'interfaces/featureToggle';
import cloneDeep from 'lodash.clonedeep';
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
import Input from 'component/common/Input/Input';
import { formatUnknownError } from 'utils/formatUnknownError';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
import { useOverrides } from './useOverrides';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
const payloadOptions = [
{ key: 'string', label: 'string' },
{ key: 'json', label: 'json' },
{ key: 'csv', label: 'csv' },
];
const EMPTY_PAYLOAD = { type: 'string', value: '' };
const StyledError = styled('p')(({ theme }) => ({
color: theme.palette.error.main,
fontSize: theme.fontSizes.smallBody,
position: 'relative',
}));
const StyledInput = styled(Input)({
maxWidth: 350,
width: '100%',
});
const StyledGrid = styled(Grid)(({ theme }) => ({
marginBottom: theme.spacing(1),
}));
const StyledLabel = styled('p')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: '1ch',
marginBottom: theme.spacing(2),
}));
const StyledGeneralSelect = styled(GeneralSelect)({
minWidth: '100px',
width: '100%',
});
interface IAddVariantProps {
showDialog: boolean;
closeDialog: () => void;
save: (variantToSave: IFeatureVariant) => Promise<void>;
editVariant: IFeatureVariant;
validateName: (value: string) => Record<string, string> | undefined;
validateWeight: (value: string) => Record<string, string> | undefined;
title: string;
editing: boolean;
}
export const AddVariant = ({
showDialog,
closeDialog,
save,
editVariant,
validateName,
validateWeight,
title,
editing,
}: IAddVariantProps) => {
const [data, setData] = useState<Record<string, string>>({});
const [payload, setPayload] = useState(EMPTY_PAYLOAD);
const [overrides, overridesDispatch] = useOverrides([]);
const [error, setError] = useState<Record<string, string>>({});
const { classes: themeStyles } = useThemeStyles();
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { feature } = useFeature(projectId, featureId);
const [variants, setVariants] = useState<IFeatureVariant[]>([]);
const { context } = useUnleashContext();
const isValidJSON = (input: string): boolean => {
try {
JSON.parse(input);
return true;
} catch (e: unknown) {
setError({
payload: 'Invalid JSON',
});
return false;
}
};
const clear = () => {
if (editVariant) {
setData({
name: editVariant.name,
weight: String(editVariant.weight / 10),
weightType: editVariant.weightType || weightTypes.VARIABLE,
stickiness: editVariant.stickiness,
});
if (editVariant.payload) {
setPayload(editVariant.payload);
} else {
setPayload(EMPTY_PAYLOAD);
}
if (editVariant.overrides) {
overridesDispatch({
type: 'SET',
payload: editVariant.overrides,
});
} else {
overridesDispatch({ type: 'CLEAR' });
}
} else {
setData({});
setPayload(EMPTY_PAYLOAD);
overridesDispatch({ type: 'CLEAR' });
}
setError({});
};
const setClonedVariants = (clonedVariants: IFeatureVariant[]) =>
setVariants(cloneDeep(clonedVariants));
useEffect(() => {
if (feature) {
setClonedVariants(feature.variants);
}
}, [feature.variants]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
clear();
}, [editVariant]); // eslint-disable-line react-hooks/exhaustive-deps
const setVariantValue = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setData({
...data,
[name]: trim(value),
});
};
const setVariantWeightType = (e: React.ChangeEvent<HTMLInputElement>) => {
const { checked, name } = e.target;
const weightType = checked ? weightTypes.FIX : weightTypes.VARIABLE;
setData({
...data,
[name]: weightType,
});
};
const submit = async (e: React.FormEvent) => {
setError({});
e.preventDefault();
const nameValidation = validateName(data.name);
if (nameValidation) {
setError(nameValidation);
return;
}
const weightValidation = validateWeight(data.weight);
if (weightValidation) {
setError(weightValidation);
return;
}
const validJSON =
payload.type === 'json' && !isValidJSON(payload.value);
if (validJSON) {
return;
}
try {
const variant: IFeatureVariant = {
name: data.name,
weight: Number(data.weight) * 10,
weightType: data.weightType,
stickiness: data.stickiness,
payload: payload.value ? payload : undefined,
overrides: overrides
.map(o => ({
contextName: o.contextName,
values: o.values,
}))
.filter(o => o.values && o.values.length > 0),
};
await save(variant);
clear();
closeDialog();
} catch (e: unknown) {
const error = formatUnknownError(e);
if (error.includes('duplicate value')) {
setError({ name: 'A variant with that name already exists.' });
} else if (error.includes('must be a number')) {
setError({ weight: 'Weight must be a number' });
} else {
const msg = error || 'Could not add variant';
setError({ general: msg });
}
}
};
const onPayload = (name: string) => (value: string) => {
setError({ payload: '' });
setPayload({ ...payload, [name]: value });
};
const onCancel = (e: React.SyntheticEvent) => {
e.preventDefault();
clear();
closeDialog();
};
const onAddOverride = () => {
if (context.length > 0) {
overridesDispatch({
type: 'ADD',
payload: { contextName: context[0].name, values: [] },
});
}
};
const isFixWeight = data.weightType === weightTypes.FIX;
const formId = 'add-feature-variant-form';
return (
<Dialogue
open={showDialog}
style={modalStyles}
onClose={onCancel}
onClick={submit}
primaryButtonText="Save"
secondaryButtonText="Cancel"
title={title}
fullWidth
maxWidth="md"
formId={formId}
>
<form
id={formId}
onSubmit={submit}
className={themeStyles.contentSpacingY}
>
<StyledError>{error.general}</StyledError>
<StyledInput
label="Variant name"
autoFocus
name="name"
id="variant-name"
errorText={error.name}
value={data.name || ''}
error={Boolean(error.name)}
required
type="name"
disabled={editing}
onChange={setVariantValue}
data-testid={'VARIANT_NAME_INPUT'}
/>
<br />
<StyledGrid container>
{/* If we're editing, we need to have at least 2 existing variants, since we require at least 1 variable. If adding, we could be adding nr 2, and as such should be allowed to set weightType to variable */}
<ConditionallyRender
condition={
(editing && variants.length > 1) ||
(!editing && variants.length > 0)
}
show={
<Grid item md={12}>
<FormControl>
<FormControlLabel
control={
<PermissionSwitch
permission={
UPDATE_FEATURE_VARIANTS
}
projectId={projectId}
name="weightType"
checked={isFixWeight}
data-testid={
'VARIANT_WEIGHT_CHECK'
}
onChange={setVariantWeightType}
/>
}
label="Custom percentage"
/>
</FormControl>
</Grid>
}
/>
<ConditionallyRender
condition={data.weightType === weightTypes.FIX}
show={
<Grid item md={4}>
<Input
id="weight"
label="Variant weight"
name="weight"
data-testid={'VARIANT_WEIGHT_INPUT'}
InputProps={{
endAdornment: (
<InputAdornment position="start">
%
</InputAdornment>
),
}}
sx={{ marginRight: '0.8rem' }}
value={data.weight}
error={Boolean(error.weight)}
errorText={error.weight}
type="number"
disabled={!isFixWeight}
onChange={e => {
setVariantValue(e);
}}
aria-valuemin={0}
aria-valuemax={100}
/>
</Grid>
}
/>
</StyledGrid>
<StyledLabel>
<strong>Payload </strong>
<HelpIcon tooltip="Passed along with the the variant object." />
</StyledLabel>
<Grid container>
<Grid item md={2} sm={2} xs={4}>
<StyledGeneralSelect
id="variant-payload-type"
name="type"
label="Type"
value={payload.type}
options={payloadOptions}
onChange={onPayload('type')}
/>
</Grid>
<Grid item md={8} sm={8} xs={6}>
<Input
error={Boolean(error.payload)}
errorText={error.payload}
name="variant-payload-value"
id="variant-payload-value"
label="Value"
multiline={payload.type !== 'string'}
rows={payload.type === 'string' ? 1 : 4}
className={themeStyles.fullWidth}
value={payload.value}
onChange={e => onPayload('value')(e.target.value)}
data-testid={'VARIANT_PAYLOAD_VALUE'}
placeholder={
payload.type === 'json'
? '{ "hello": "world" }'
: ''
}
/>
</Grid>
</Grid>
<ConditionallyRender
condition={overrides.length > 0}
show={
<StyledLabel>
<strong>Overrides </strong>
<HelpIcon tooltip="Here you can specify which users should get this variant." />
</StyledLabel>
}
/>
<OverrideConfig
overrides={overrides}
overridesDispatch={overridesDispatch}
/>
<Button
onClick={onAddOverride}
variant="contained"
color="primary"
>
Add override
</Button>
</form>
</Dialogue>
);
};

View File

@ -1,152 +0,0 @@
import { ChangeEvent, VFC } from 'react';
import classnames from 'classnames';
import { Grid, IconButton, TextField, Tooltip } from '@mui/material';
import { Delete } from '@mui/icons-material';
import { Autocomplete } from '@mui/material';
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
import { useThemeStyles } from 'themes/themeStyles';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { InputListField } from 'component/common/InputListField/InputListField';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
import { IOverride } from 'interfaces/featureToggle';
import { OverridesDispatchType } from '../useOverrides';
interface IOverrideConfigProps {
overrides: IOverride[];
overridesDispatch: OverridesDispatchType;
}
export const OverrideConfig: VFC<IOverrideConfigProps> = ({
overrides,
overridesDispatch,
}) => {
const { classes: themeStyles } = useThemeStyles();
const { context } = useUnleashContext();
const contextNames = context.map(({ name }) => ({
key: name,
label: name,
}));
const updateValues = (index: number) => (values: string[]) => {
overridesDispatch({
type: 'UPDATE_VALUES_AT',
payload: [index, values],
});
};
const updateSelectValues =
(index: number) => (event: ChangeEvent<unknown>, options: string[]) => {
event?.preventDefault();
overridesDispatch({
type: 'UPDATE_VALUES_AT',
payload: [index, options ? options : []],
});
};
return (
<>
{overrides.map((override, index) => {
const definition = context.find(
c => c.name === override.contextName
);
const legalValues =
definition?.legalValues?.map(({ value }) => value) || [];
const filteredValues = override.values.filter(value =>
legalValues.includes(value)
);
return (
<Grid
container
key={`override=${index}`}
alignItems="center"
>
<Grid
item
md={3}
sm={3}
xs={3}
sx={theme => ({ marginRight: theme.spacing(1) })}
>
<GeneralSelect
name="contextName"
label="Context Field"
value={override.contextName}
options={contextNames}
classes={{
root: classnames(themeStyles.fullWidth),
}}
onChange={(value: string) => {
overridesDispatch({
type: 'UPDATE_TYPE_AT',
payload: [index, value],
});
}}
/>
</Grid>
<Grid md={7} sm={7} xs={6} item>
<ConditionallyRender
condition={Boolean(
legalValues && legalValues.length > 0
)}
show={
<Autocomplete
multiple
id={`override-select-${index}`}
isOptionEqualToValue={(
option,
value
) => {
return option === value;
}}
options={legalValues}
onChange={updateSelectValues(index)}
getOptionLabel={option => option}
value={filteredValues}
style={{ width: '100%' }}
filterSelectedOptions
size="small"
renderInput={params => (
<TextField
{...params}
variant="outlined"
label="Legal values"
style={{ width: '100%' }}
/>
)}
/>
}
elseShow={
<InputListField
label="Values (v1, v2, ...)"
name="values"
placeholder=""
values={override.values}
updateValues={updateValues(index)}
/>
}
/>
</Grid>
<Grid item md={1}>
<Tooltip title="Remove" arrow>
<IconButton
onClick={event => {
event.preventDefault();
overridesDispatch({
type: 'REMOVE',
payload: index,
});
}}
size="large"
>
<Delete />
</IconButton>
</Tooltip>
</Grid>
</Grid>
);
})}
</>
);
};

View File

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

View File

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

View File

@ -1,489 +0,0 @@
import * as jsonpatch from 'fast-json-patch';
import {
Alert,
Table,
TableBody,
TableCell,
TableRow,
useMediaQuery,
} from '@mui/material';
import { AddVariant } from './AddFeatureVariant/AddFeatureVariant';
import { useContext, useEffect, useState, useMemo, useCallback } from 'react';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import AccessContext from 'contexts/AccessContext';
import { UPDATE_FEATURE_VARIANTS } from 'component/providers/AccessProvider/permissions';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
import { IFeatureVariant } from 'interfaces/featureToggle';
import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
import useToast from 'hooks/useToast';
import { calculateVariantWeight, updateWeight } from 'component/common/util';
import cloneDeep from 'lodash.clonedeep';
import useDeleteVariantMarkup from './useDeleteVariantMarkup';
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useTable, useSortBy, useGlobalFilter } from 'react-table';
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { SortableTableHeader, TablePlaceholder } from 'component/common/Table';
import { sortTypes } from 'utils/sortTypes';
import { PayloadOverridesCell } from './PayloadOverridesCell/PayloadOverridesCell';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import theme from 'themes/theme';
import { VariantsActionCell } from './VariantsActionsCell/VariantsActionsCell';
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
export const FeatureVariantsList = () => {
const { hasAccess } = useContext(AccessContext);
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { feature, refetchFeature, loading } = useFeature(
projectId,
featureId
);
const [variants, setVariants] = useState<IFeatureVariant[]>([]);
const [editing, setEditing] = useState(false);
const { context } = useUnleashContext();
const { setToastData, setToastApiError } = useToast();
const { patchFeatureVariants } = useFeatureApi();
const [variantToEdit, setVariantToEdit] = useState({});
const [showAddVariant, setShowAddVariant] = useState(false);
const [stickinessOptions, setStickinessOptions] = useState<string[]>([]);
const [delDialog, setDelDialog] = useState({ name: '', show: false });
const isMediumScreen = useMediaQuery(theme.breakpoints.down('md'));
const isLargeScreen = useMediaQuery(theme.breakpoints.down('lg'));
useEffect(() => {
if (feature) {
setClonedVariants(feature.variants);
}
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [feature.variants]);
useEffect(() => {
const options = [
'default',
...context.filter(c => c.stickiness).map(c => c.name),
];
setStickinessOptions(options);
}, [context]);
const editable = hasAccess(UPDATE_FEATURE_VARIANTS, projectId);
const data = useMemo(() => {
if (loading) {
return Array(5).fill({
name: 'Context name',
description: 'Context description when loading',
});
}
return feature.variants;
}, [feature.variants, loading]);
const editVariant = useCallback(
(name: string) => {
const variant = {
...variants.find(variant => variant.name === name),
};
setVariantToEdit(variant);
setEditing(true);
setShowAddVariant(true);
},
[variants, setVariantToEdit, setEditing, setShowAddVariant]
);
const columns = useMemo(
() => [
{
Header: 'Name',
accessor: 'name',
width: '25%',
Cell: ({
row: {
original: { name },
},
}: any) => {
return <TextCell data-loading>{name}</TextCell>;
},
sortType: 'alphanumeric',
},
{
Header: 'Payload/Overrides',
accessor: 'data',
Cell: ({
row: {
original: { overrides, payload },
},
}: any) => {
return (
<PayloadOverridesCell
overrides={overrides}
payload={payload}
/>
);
},
disableSortBy: true,
},
{
Header: 'Weight',
accessor: 'weight',
width: '20%',
Cell: ({
row: {
original: { name, weight },
},
}: any) => {
return (
<TextCell data-testid={`VARIANT_WEIGHT_${name}`}>
{calculateVariantWeight(weight)} %
</TextCell>
);
},
sortType: 'number',
},
{
Header: 'Type',
accessor: 'weightType',
width: '20%',
Cell: ({
row: {
original: { weightType },
},
}: any) => {
return <TextCell>{weightType}</TextCell>;
},
sortType: 'alphanumeric',
},
{
Header: 'Actions',
id: 'Actions',
align: 'right',
Cell: ({ row: { original } }: any) => (
<VariantsActionCell
editVariant={editVariant}
setDelDialog={setDelDialog}
variant={original as IFeatureVariant}
projectId={projectId}
/>
),
width: 150,
disableSortBy: true,
},
],
[projectId, editVariant]
);
const initialState = useMemo(
() => ({
sortBy: [{ id: 'name', desc: false }],
}),
[]
);
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
setHiddenColumns,
} = useTable(
{
columns: columns as any[],
data: data as any[],
initialState,
sortTypes,
autoResetHiddenColumns: false,
autoResetGlobalFilter: false,
autoResetSortBy: false,
disableSortRemove: true,
},
useGlobalFilter,
useSortBy
);
useConditionallyHiddenColumns(
[
{
condition: isMediumScreen,
columns: ['data'],
},
{
condition: isLargeScreen,
columns: ['weightType'],
},
],
setHiddenColumns,
columns
);
// @ts-expect-error
const setClonedVariants = clonedVariants =>
setVariants(cloneDeep(clonedVariants));
const handleCloseAddVariant = () => {
setShowAddVariant(false);
setEditing(false);
setVariantToEdit({});
};
const renderStickiness = () => {
if (!variants || variants.length < 2) {
return null;
}
const value = variants[0].stickiness || 'default';
const options = stickinessOptions.map(c => ({ key: c, label: c }));
// guard on stickiness being disabled for context field.
if (!stickinessOptions.includes(value)) {
options.push({ key: value, label: value });
}
const onChange = (value: string) => {
updateStickiness(value).catch(console.warn);
};
return (
<section style={{ paddingTop: '16px' }}>
<GeneralSelect
label="Stickiness"
options={options}
value={value}
onChange={onChange}
/>
&nbsp;&nbsp;
<small style={{ display: 'block', marginTop: '0.5rem' }}>
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>
</small>
</section>
);
};
const updateStickiness = async (stickiness: string) => {
const newVariants = [...variants].map(variant => {
return { ...variant, stickiness };
});
const patch = createPatch(newVariants);
if (patch.length === 0) return;
try {
await patchFeatureVariants(projectId, featureId, patch);
refetchFeature();
setToastData({
title: 'Updated variant',
confetti: true,
type: 'success',
text: 'Successfully updated variant stickiness',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
const removeVariant = async (name: string) => {
let updatedVariants = variants.filter(value => value.name !== name);
try {
await updateVariants(
updatedVariants,
'Successfully removed variant'
);
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
const updateVariant = async (variant: IFeatureVariant) => {
const updatedVariants = cloneDeep(variants);
const variantIdxToUpdate = updatedVariants.findIndex(
(v: IFeatureVariant) => v.name === variant.name
);
updatedVariants[variantIdxToUpdate] = variant;
await updateVariants(updatedVariants, 'Successfully updated variant');
};
const saveNewVariant = async (variant: IFeatureVariant) => {
let stickiness = 'default';
if (variants?.length > 0) {
stickiness = variants[0].stickiness || 'default';
}
variant.stickiness = stickiness;
await updateVariants(
[...variants, variant],
'Successfully added a variant'
);
};
const updateVariants = async (
variants: IFeatureVariant[],
successText: string
) => {
const newVariants = updateWeight(variants, 1000);
const patch = createPatch(newVariants);
if (patch.length === 0) return;
try {
await patchFeatureVariants(projectId, featureId, patch);
refetchFeature();
setToastData({
title: 'Updated variant',
type: 'success',
text: successText,
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
const validateName = (name: string) => {
if (!name) {
return { name: 'Name is required' };
}
};
const validateWeight = (weight: string) => {
const weightValue = parseInt(weight);
if (weightValue > 100 || weightValue < 0) {
return { weight: 'weight must be between 0 and 100' };
}
};
const delDialogueMarkup = useDeleteVariantMarkup({
show: delDialog.show,
onClick: () => {
removeVariant(delDialog.name);
setDelDialog({ name: '', show: false });
setToastData({
title: 'Deleted variant',
type: 'success',
text: `Successfully deleted variant`,
});
},
onClose: () => setDelDialog({ show: false, name: '' }),
});
const createPatch = (newVariants: IFeatureVariant[]) => {
return jsonpatch.compare(feature.variants, newVariants);
};
const addVariant = () => {
setEditing(false);
if (variants.length === 0) {
setVariantToEdit({ weight: 1000 });
} else {
setVariantToEdit({
weightType: 'variable',
});
}
setShowAddVariant(true);
};
return (
<PageContent
isLoading={loading}
header={
<PageHeader
title={`Variants (${rows.length})`}
actions={
<>
<PermissionButton
onClick={addVariant}
data-testid={'ADD_VARIANT_BUTTON'}
permission={UPDATE_FEATURE_VARIANTS}
projectId={projectId}
>
New variant
</PermissionButton>
</>
}
/>
}
>
<Alert severity="info" sx={{ marginBottom: '1rem' }}>
Variants allows you to return a variant object if the feature
toggle is considered enabled for the current request. When using
variants you should use the{' '}
<code style={{ fontWeight: 'bold' }}>getVariant()</code> method
in the Client SDK.
</Alert>
<Table {...getTableProps()}>
<SortableTableHeader headerGroups={headerGroups} />
<TableBody {...getTableBodyProps()}>
{rows.map(row => {
prepareRow(row);
return (
<TableRow
hover
{...row.getRowProps()}
style={{ height: '75px' }}
>
{row.cells.map(cell => (
<TableCell
{...cell.getCellProps()}
padding="none"
>
{cell.render('Cell')}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
<ConditionallyRender
condition={rows.length === 0}
show={
<TablePlaceholder>
No variants available. Get started by adding one.
</TablePlaceholder>
}
/>
<br />
<div>
<ConditionallyRender
condition={editable}
show={renderStickiness()}
/>
</div>
<AddVariant
showDialog={showAddVariant}
closeDialog={handleCloseAddVariant}
save={async (variantToSave: IFeatureVariant) => {
if (!editing) {
return saveNewVariant(variantToSave);
} else {
return updateVariant(variantToSave);
}
}}
editing={editing}
validateName={validateName}
validateWeight={validateWeight}
// @ts-expect-error
editVariant={variantToEdit}
title={editing ? 'Edit variant' : 'Add variant'}
/>
{delDialogueMarkup}
</PageContent>
);
};

View File

@ -1,26 +0,0 @@
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { IOverride, IPayload } from 'interfaces/featureToggle';
interface IPayloadOverridesCellProps {
payload: IPayload;
overrides: IOverride[];
}
export const PayloadOverridesCell = ({
payload,
overrides,
}: IPayloadOverridesCellProps) => {
return (
<>
<ConditionallyRender
condition={Boolean(payload)}
show={<TextCell>Payload</TextCell>}
/>
<ConditionallyRender
condition={overrides && overrides.length > 0}
show={<TextCell>Overrides</TextCell>}
/>
</>
);
};

View File

@ -1,55 +0,0 @@
import { Edit, Delete } from '@mui/icons-material';
import { Box } from '@mui/material';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { UPDATE_FEATURE_VARIANTS } from 'component/providers/AccessProvider/permissions';
import { IFeatureVariant } from 'interfaces/featureToggle';
interface IVarintsActionCellProps {
projectId: string;
editVariant: (name: string) => void;
setDelDialog: React.Dispatch<
React.SetStateAction<{
name: string;
show: boolean;
}>
>;
variant: IFeatureVariant;
}
export const VariantsActionCell = ({
projectId,
setDelDialog,
variant,
editVariant,
}: IVarintsActionCellProps) => {
return (
<Box
style={{ display: 'flex', justifyContent: 'flex-end' }}
data-loading
>
<PermissionIconButton
size="large"
data-testid={`VARIANT_EDIT_BUTTON_${variant.name}`}
permission={UPDATE_FEATURE_VARIANTS}
projectId={projectId}
onClick={() => editVariant(variant.name)}
>
<Edit />
</PermissionIconButton>
<PermissionIconButton
size="large"
permission={UPDATE_FEATURE_VARIANTS}
data-testid={`VARIANT_DELETE_BUTTON_${variant.name}`}
projectId={projectId}
onClick={() =>
setDelDialog({
show: true,
name: variant.name,
})
}
>
<Delete />
</PermissionIconButton>
</Box>
);
};

View File

@ -1,31 +0,0 @@
import { Alert } from '@mui/material';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
interface IUseDeleteVariantMarkupProps {
show: boolean;
onClick: () => void;
onClose: () => void;
}
const useDeleteVariantMarkup = ({
show,
onClick,
onClose,
}: IUseDeleteVariantMarkupProps) => {
return (
<Dialogue
title="Are you sure you want to delete this variant?"
open={show}
primaryButtonText="Delete variant"
secondaryButtonText="Cancel"
onClick={onClick}
onClose={onClose}
>
<Alert severity="error">
Deleting this variant will change which variant users receive.
</Alert>
</Dialogue>
);
};
export default useDeleteVariantMarkup;

View File

@ -18,7 +18,7 @@ import {
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import FeatureLog from './FeatureLog/FeatureLog';
import FeatureOverview from './FeatureOverview/FeatureOverview';
import FeatureVariants from './FeatureVariants/FeatureVariants';
import { FeatureEnvironmentVariants } from './FeatureVariants/FeatureEnvironmentVariants/FeatureEnvironmentVariants';
import { FeatureMetrics } from './FeatureMetrics/FeatureMetrics';
import { FeatureSettings } from './FeatureSettings/FeatureSettings';
import useLoading from 'hooks/useLoading';
@ -233,7 +233,10 @@ export const FeatureView = () => {
<Routes>
<Route path="metrics" element={<FeatureMetrics />} />
<Route path="logs" element={<FeatureLog />} />
<Route path="variants" element={<FeatureVariants />} />
<Route
path="variants"
element={<FeatureEnvironmentVariants />}
/>
<Route path="settings" element={<FeatureSettings />} />
<Route path="*" element={<FeatureOverview />} />
</Routes>