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:
parent
6c72efb300
commit
f30a8a66b0
@ -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';
|
||||
|
@ -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>
|
||||
|
||||
<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;
|
@ -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;
|
@ -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;
|
||||
|
||||
|
@ -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;
|
@ -327,6 +327,9 @@ export const FeatureEnvironmentVariants = () => {
|
||||
}
|
||||
projectId={projectId}
|
||||
environmentId={environment.name}
|
||||
tooltipProps={{
|
||||
title: 'Edit variants',
|
||||
}}
|
||||
>
|
||||
<Edit />
|
||||
</PermissionIconButton>
|
||||
|
@ -1,9 +0,0 @@
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
export const useStyles = makeStyles()(theme => ({
|
||||
container: {
|
||||
borderRadius: '12.5px',
|
||||
backgroundColor: '#fff',
|
||||
padding: '2rem',
|
||||
},
|
||||
}));
|
@ -1,10 +0,0 @@
|
||||
import { usePageTitle } from 'hooks/usePageTitle';
|
||||
import { FeatureEnvironmentVariants } from './FeatureEnvironmentVariants/FeatureEnvironmentVariants';
|
||||
|
||||
const FeatureVariants = () => {
|
||||
usePageTitle('Variants');
|
||||
|
||||
return <FeatureEnvironmentVariants />;
|
||||
};
|
||||
|
||||
export default FeatureVariants;
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
@ -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'] },
|
||||
]);
|
||||
});
|
||||
});
|
@ -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];
|
@ -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}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
@ -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>}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user