mirror of
https://github.com/Unleash/unleash.git
synced 2025-02-23 00:22:19 +01:00
Variants per environment (frontend) (#2453)
 ## About the changes https://linear.app/unleash/issue/2-425/variants-crud-new-environment-cards-with-tables-inside-add-edit-and Basically created parallel components for the **variants per environments** feature, so both flows should work correctly depending on the feature flag state. Some of the duplication means that cleanup should be straightforward - Once we're happy with this feature it should be enough to delete `frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList` and do some little extra cleanup. I noticed we had some legacy-looking code in variants, so this involved *some* rewriting of the current variants logic. Hopefully this new code looks nicer, more maintainable, and more importantly **doesn't break anything**. Relates to [roadmap](https://github.com/orgs/Unleash/projects/10) item: #2254 ### Important files Everything inside the `frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants` folder.
This commit is contained in:
parent
31dc31fdf4
commit
93bd9d869a
@ -33,7 +33,6 @@ export const DateTimePicker = ({
|
||||
max,
|
||||
value,
|
||||
onChange,
|
||||
InputProps,
|
||||
...rest
|
||||
}: IDateTimePickerProps) => {
|
||||
const getDate = type === 'datetime' ? formatDateTime : formatDate;
|
||||
|
@ -3,7 +3,7 @@ import { makeStyles } from 'tss-react/mui';
|
||||
export const useStyles = makeStyles()(theme => ({
|
||||
helperText: {
|
||||
position: 'absolute',
|
||||
top: '35px',
|
||||
bottom: '-1rem',
|
||||
},
|
||||
inputContainer: {
|
||||
width: '100%',
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { INPUT_ERROR_TEXT } from 'utils/testIds';
|
||||
import { useStyles } from './Input.styles';
|
||||
import React from 'react';
|
||||
import { TextField, OutlinedTextFieldProps } from '@mui/material';
|
||||
|
||||
interface IInputProps extends Omit<OutlinedTextFieldProps, 'variant'> {
|
||||
@ -26,7 +25,6 @@ const Input = ({
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
InputProps,
|
||||
...rest
|
||||
}: IInputProps) => {
|
||||
const { classes: styles } = useStyles();
|
||||
|
@ -94,12 +94,12 @@ enum ErrorField {
|
||||
PROJECTS = 'projects',
|
||||
}
|
||||
|
||||
interface ICreatePersonalAPITokenErrors {
|
||||
interface IEnvironmentCloneModalErrors {
|
||||
[ErrorField.NAME]?: string;
|
||||
[ErrorField.PROJECTS]?: string;
|
||||
}
|
||||
|
||||
interface ICreatePersonalAPITokenProps {
|
||||
interface IEnvironmentCloneModalProps {
|
||||
environment: IEnvironment;
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
@ -111,7 +111,7 @@ export const EnvironmentCloneModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
newToken,
|
||||
}: ICreatePersonalAPITokenProps) => {
|
||||
}: IEnvironmentCloneModalProps) => {
|
||||
const { environments, refetchEnvironments } = useEnvironments();
|
||||
const { cloneEnvironment, loading } = useEnvironmentApi();
|
||||
const { createToken } = useApiTokensApi();
|
||||
@ -126,7 +126,7 @@ export const EnvironmentCloneModal = ({
|
||||
const [clonePermissions, setClonePermissions] = useState(true);
|
||||
const [apiTokenGeneration, setApiTokenGeneration] =
|
||||
useState<APITokenGeneration>(APITokenGeneration.LATER);
|
||||
const [errors, setErrors] = useState<ICreatePersonalAPITokenErrors>({});
|
||||
const [errors, setErrors] = useState<IEnvironmentCloneModalErrors>({});
|
||||
|
||||
const clearError = (field: ErrorField) => {
|
||||
setErrors(errors => ({ ...errors, [field]: undefined }));
|
||||
|
@ -0,0 +1,506 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
FormControlLabel,
|
||||
InputAdornment,
|
||||
styled,
|
||||
} from '@mui/material';
|
||||
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
|
||||
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
import Input from 'component/common/Input/Input';
|
||||
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import {
|
||||
IFeatureEnvironment,
|
||||
IFeatureVariant,
|
||||
IPayload,
|
||||
} from 'interfaces/featureToggle';
|
||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||
import { Operation } from 'fast-json-patch';
|
||||
import { useOverrides } from 'component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/useOverrides';
|
||||
import SelectMenu from 'component/common/select';
|
||||
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
|
||||
import { OverrideConfig } from 'component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/VariantOverrides';
|
||||
import cloneDeep from 'lodash.clonedeep';
|
||||
import { CloudCircle } from '@mui/icons-material';
|
||||
import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch';
|
||||
import { UPDATE_FEATURE_VARIANTS } from 'component/providers/AccessProvider/permissions';
|
||||
|
||||
const StyledFormSubtitle = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginTop: theme.spacing(-1.5),
|
||||
marginBottom: theme.spacing(4),
|
||||
}));
|
||||
|
||||
const StyledCloudCircle = styled(CloudCircle, {
|
||||
shouldForwardProp: prop => prop !== 'deprecated',
|
||||
})<{ deprecated?: boolean }>(({ theme, deprecated }) => ({
|
||||
color: deprecated
|
||||
? theme.palette.neutral.border
|
||||
: theme.palette.primary.main,
|
||||
}));
|
||||
|
||||
const StyledName = styled('span', {
|
||||
shouldForwardProp: prop => prop !== 'deprecated',
|
||||
})<{ deprecated?: boolean }>(({ theme, deprecated }) => ({
|
||||
color: deprecated
|
||||
? theme.palette.text.secondary
|
||||
: theme.palette.text.primary,
|
||||
marginLeft: theme.spacing(1.25),
|
||||
}));
|
||||
|
||||
const StyledForm = styled('form')(() => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
}));
|
||||
|
||||
const StyledInputDescription = styled('p')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
color: theme.palette.text.primary,
|
||||
marginBottom: theme.spacing(1),
|
||||
'&:not(:first-of-type)': {
|
||||
marginTop: theme.spacing(4),
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledFormControlLabel = styled(FormControlLabel)(({ theme }) => ({
|
||||
marginTop: theme.spacing(4),
|
||||
marginBottom: theme.spacing(1.5),
|
||||
}));
|
||||
|
||||
const StyledInputSecondaryDescription = styled('p')(({ theme }) => ({
|
||||
color: theme.palette.text.secondary,
|
||||
marginBottom: theme.spacing(1),
|
||||
}));
|
||||
|
||||
const StyledInput = styled(Input)(() => ({
|
||||
width: '100%',
|
||||
}));
|
||||
|
||||
const StyledRow = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
rowGap: theme.spacing(1.5),
|
||||
marginBottom: theme.spacing(2),
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
flexDirection: 'column',
|
||||
'& > div, .MuiInputBase-root': {
|
||||
width: '100%',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledSelectMenu = styled(SelectMenu)(({ theme }) => ({
|
||||
minWidth: theme.spacing(20),
|
||||
marginRight: theme.spacing(10),
|
||||
}));
|
||||
|
||||
const StyledAlert = styled(Alert)(({ theme }) => ({
|
||||
marginTop: theme.spacing(4),
|
||||
}));
|
||||
|
||||
const StyledButtonContainer = styled('div')(({ theme }) => ({
|
||||
marginTop: 'auto',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
marginTop: theme.spacing(4),
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledCancelButton = styled(Button)(({ theme }) => ({
|
||||
marginLeft: theme.spacing(3),
|
||||
}));
|
||||
|
||||
const payloadOptions = [
|
||||
{ key: 'string', label: 'string' },
|
||||
{ key: 'json', label: 'json' },
|
||||
{ key: 'csv', label: 'csv' },
|
||||
];
|
||||
|
||||
enum WeightType {
|
||||
FIX = 'fix',
|
||||
VARIABLE = 'variable',
|
||||
}
|
||||
|
||||
const EMPTY_PAYLOAD = { type: 'string', value: '' };
|
||||
|
||||
enum ErrorField {
|
||||
NAME = 'name',
|
||||
PERCENTAGE = 'percentage',
|
||||
PAYLOAD = 'payload',
|
||||
OTHER = 'other',
|
||||
}
|
||||
|
||||
interface IEnvironmentVariantModalErrors {
|
||||
[ErrorField.NAME]?: string;
|
||||
[ErrorField.PERCENTAGE]?: string;
|
||||
[ErrorField.PAYLOAD]?: string;
|
||||
[ErrorField.OTHER]?: string;
|
||||
}
|
||||
|
||||
interface IEnvironmentVariantModalProps {
|
||||
environment?: IFeatureEnvironment;
|
||||
variant?: IFeatureVariant;
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
getApiPayload: (
|
||||
variants: IFeatureVariant[],
|
||||
newVariants: IFeatureVariant[]
|
||||
) => { patch: Operation[]; error?: string };
|
||||
onConfirm: (updatedVariants: IFeatureVariant[]) => void;
|
||||
}
|
||||
|
||||
export const EnvironmentVariantModal = ({
|
||||
environment,
|
||||
variant,
|
||||
open,
|
||||
setOpen,
|
||||
getApiPayload,
|
||||
onConfirm,
|
||||
}: IEnvironmentVariantModalProps) => {
|
||||
const projectId = useRequiredPathParam('projectId');
|
||||
const featureId = useRequiredPathParam('featureId');
|
||||
|
||||
const { uiConfig } = useUiConfig();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [customPercentage, setCustomPercentage] = useState(false);
|
||||
const [percentage, setPercentage] = useState('');
|
||||
const [payload, setPayload] = useState<IPayload>(EMPTY_PAYLOAD);
|
||||
const [overrides, overridesDispatch] = useOverrides([]);
|
||||
const { context } = useUnleashContext();
|
||||
|
||||
const [errors, setErrors] = useState<IEnvironmentVariantModalErrors>({});
|
||||
|
||||
const clearError = (field: ErrorField) => {
|
||||
setErrors(errors => ({ ...errors, [field]: undefined }));
|
||||
};
|
||||
|
||||
const setError = (field: ErrorField, error: string) => {
|
||||
setErrors(errors => ({ ...errors, [field]: error }));
|
||||
};
|
||||
|
||||
const editing = Boolean(variant);
|
||||
const variants = environment?.variants || [];
|
||||
const customPercentageVisible =
|
||||
(editing && variants.length > 1) || (!editing && variants.length > 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (variant) {
|
||||
setName(variant.name);
|
||||
setCustomPercentage(variant.weightType === WeightType.FIX);
|
||||
setPercentage(String(variant.weight / 10));
|
||||
setPayload(variant.payload || EMPTY_PAYLOAD);
|
||||
overridesDispatch(
|
||||
variant.overrides
|
||||
? { type: 'SET', payload: variant.overrides || [] }
|
||||
: { type: 'CLEAR' }
|
||||
);
|
||||
} else {
|
||||
setName('');
|
||||
setCustomPercentage(false);
|
||||
setPercentage('');
|
||||
setPayload(EMPTY_PAYLOAD);
|
||||
overridesDispatch({ type: 'CLEAR' });
|
||||
}
|
||||
setErrors({});
|
||||
}, [open, variant]);
|
||||
|
||||
const getUpdatedVariants = (): IFeatureVariant[] => {
|
||||
const newVariant: IFeatureVariant = {
|
||||
name,
|
||||
weight: Number(customPercentage ? percentage : 100) * 10,
|
||||
weightType: customPercentage ? WeightType.FIX : WeightType.VARIABLE,
|
||||
stickiness:
|
||||
variants?.length > 0 ? variants[0].stickiness : 'default',
|
||||
payload: payload.value ? payload : undefined,
|
||||
overrides: overrides
|
||||
.map(o => ({
|
||||
contextName: o.contextName,
|
||||
values: o.values,
|
||||
}))
|
||||
.filter(o => o.values && o.values.length > 0),
|
||||
};
|
||||
|
||||
const updatedVariants = cloneDeep(variants);
|
||||
|
||||
if (editing) {
|
||||
const variantIdxToUpdate = updatedVariants.findIndex(
|
||||
(variant: IFeatureVariant) => variant.name === newVariant.name
|
||||
);
|
||||
updatedVariants[variantIdxToUpdate] = newVariant;
|
||||
} else {
|
||||
updatedVariants.push(newVariant);
|
||||
}
|
||||
|
||||
return updatedVariants;
|
||||
};
|
||||
|
||||
const apiPayload = getApiPayload(variants, getUpdatedVariants());
|
||||
|
||||
useEffect(() => {
|
||||
clearError(ErrorField.PERCENTAGE);
|
||||
clearError(ErrorField.OTHER);
|
||||
if (apiPayload.error) {
|
||||
if (apiPayload.error.includes('%')) {
|
||||
setError(ErrorField.PERCENTAGE, apiPayload.error);
|
||||
} else {
|
||||
setError(ErrorField.OTHER, apiPayload.error);
|
||||
}
|
||||
}
|
||||
}, [apiPayload.error]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
onConfirm(getUpdatedVariants());
|
||||
};
|
||||
|
||||
const formatApiCode = () => `curl --location --request PATCH '${
|
||||
uiConfig.unleashUrl
|
||||
}/api/admin/projects/${projectId}/features/${featureId}/environments/${
|
||||
environment?.name
|
||||
}/variants' \\
|
||||
--header 'Authorization: INSERT_API_KEY' \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--data-raw '${JSON.stringify(apiPayload.patch, undefined, 2)}'`;
|
||||
|
||||
const isNameNotEmpty = (name: string) => name.length;
|
||||
const isNameUnique = (name: string) =>
|
||||
editing || !variants.some(variant => variant.name === name);
|
||||
const isValidPercentage = (percentage: string) => {
|
||||
if (!customPercentage) return true;
|
||||
if (percentage === '') return false;
|
||||
|
||||
const percentageNumber = Number(percentage);
|
||||
return percentageNumber >= 0 && percentageNumber <= 100;
|
||||
};
|
||||
const isValidPayload = (payload: IPayload): boolean => {
|
||||
try {
|
||||
if (payload.type === 'json') {
|
||||
JSON.parse(payload.value);
|
||||
}
|
||||
return true;
|
||||
} catch (e: unknown) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const isValid =
|
||||
isNameNotEmpty(name) &&
|
||||
isNameUnique(name) &&
|
||||
isValidPercentage(percentage) &&
|
||||
isValidPayload(payload) &&
|
||||
!apiPayload.error;
|
||||
|
||||
const onSetName = (name: string) => {
|
||||
clearError(ErrorField.NAME);
|
||||
if (!isNameUnique(name)) {
|
||||
setError(
|
||||
ErrorField.NAME,
|
||||
'A variant with that name already exists for this environment.'
|
||||
);
|
||||
}
|
||||
setName(name);
|
||||
};
|
||||
|
||||
const onSetPercentage = (percentage: string) => {
|
||||
if (percentage === '' || isValidPercentage(percentage)) {
|
||||
setPercentage(percentage);
|
||||
}
|
||||
};
|
||||
|
||||
const validatePayload = (payload: IPayload) => {
|
||||
if (!isValidPayload(payload)) {
|
||||
setError(ErrorField.PAYLOAD, 'Invalid JSON.');
|
||||
}
|
||||
};
|
||||
|
||||
const onAddOverride = () => {
|
||||
if (context.length > 0) {
|
||||
overridesDispatch({
|
||||
type: 'ADD',
|
||||
payload: { contextName: context[0].name, values: [] },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarModal
|
||||
open={open}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
label={editing ? 'Edit variant' : 'Add variant'}
|
||||
>
|
||||
<FormTemplate
|
||||
modal
|
||||
title={editing ? 'Edit variant' : 'Add variant'}
|
||||
description="Variants allows you to return a variant object if the feature toggle is considered enabled for the current request."
|
||||
documentationLink="https://docs.getunleash.io/advanced/toggle_variants"
|
||||
documentationLinkLabel="Feature toggle variants documentation"
|
||||
formatApiCode={formatApiCode}
|
||||
loading={!open}
|
||||
>
|
||||
<StyledFormSubtitle>
|
||||
<StyledCloudCircle deprecated={!environment?.enabled} />
|
||||
<StyledName deprecated={!environment?.enabled}>
|
||||
{environment?.name}
|
||||
</StyledName>
|
||||
</StyledFormSubtitle>
|
||||
<StyledForm onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<StyledInputDescription>
|
||||
Variant name
|
||||
</StyledInputDescription>
|
||||
<StyledInputSecondaryDescription>
|
||||
This will be used to identify the variant in your
|
||||
code
|
||||
</StyledInputSecondaryDescription>
|
||||
<StyledInput
|
||||
autoFocus
|
||||
label="Variant name"
|
||||
error={Boolean(errors.name)}
|
||||
errorText={errors.name}
|
||||
value={name}
|
||||
onChange={e => onSetName(e.target.value)}
|
||||
disabled={editing}
|
||||
required
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={customPercentageVisible}
|
||||
show={
|
||||
<StyledFormControlLabel
|
||||
label="Custom percentage"
|
||||
control={
|
||||
<PermissionSwitch
|
||||
permission={UPDATE_FEATURE_VARIANTS}
|
||||
projectId={projectId}
|
||||
checked={customPercentage}
|
||||
onChange={e =>
|
||||
setCustomPercentage(
|
||||
e.target.checked
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={customPercentage}
|
||||
show={
|
||||
<StyledInput
|
||||
type="number"
|
||||
label="Variant weight"
|
||||
error={Boolean(errors.percentage)}
|
||||
errorText={errors.percentage}
|
||||
value={percentage}
|
||||
onChange={e =>
|
||||
onSetPercentage(e.target.value)
|
||||
}
|
||||
required={customPercentage}
|
||||
disabled={!customPercentage}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
%
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<StyledInputDescription>
|
||||
Payload
|
||||
<HelpIcon tooltip="Passed along with the the variant object." />
|
||||
</StyledInputDescription>
|
||||
<StyledRow>
|
||||
<StyledSelectMenu
|
||||
id="variant-payload-type"
|
||||
name="type"
|
||||
label="Type"
|
||||
value={payload.type}
|
||||
options={payloadOptions}
|
||||
onChange={e => {
|
||||
clearError(ErrorField.PAYLOAD);
|
||||
setPayload(payload => ({
|
||||
...payload,
|
||||
type: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<StyledInput
|
||||
id="variant-payload-value"
|
||||
name="variant-payload-value"
|
||||
label="Value"
|
||||
multiline={payload.type !== 'string'}
|
||||
rows={payload.type === 'string' ? 1 : 4}
|
||||
value={payload.value}
|
||||
onChange={e => {
|
||||
clearError(ErrorField.PAYLOAD);
|
||||
setPayload(payload => ({
|
||||
...payload,
|
||||
value: e.target.value,
|
||||
}));
|
||||
}}
|
||||
placeholder={
|
||||
payload.type === 'json'
|
||||
? '{ "hello": "world" }'
|
||||
: ''
|
||||
}
|
||||
onBlur={() => validatePayload(payload)}
|
||||
error={Boolean(errors.payload)}
|
||||
errorText={errors.payload}
|
||||
/>
|
||||
</StyledRow>
|
||||
<StyledInputDescription>
|
||||
Overrides
|
||||
<HelpIcon tooltip="Here you can specify which users should get this variant." />
|
||||
</StyledInputDescription>
|
||||
<OverrideConfig
|
||||
overrides={overrides}
|
||||
overridesDispatch={overridesDispatch}
|
||||
/>
|
||||
<Button
|
||||
onClick={onAddOverride}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
>
|
||||
Add override
|
||||
</Button>
|
||||
</div>
|
||||
<StyledAlert
|
||||
severity="error"
|
||||
hidden={!Boolean(errors.other)}
|
||||
>
|
||||
<strong>Error: </strong>
|
||||
{errors.other}
|
||||
</StyledAlert>
|
||||
|
||||
<StyledButtonContainer>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={!isValid}
|
||||
>
|
||||
{editing ? 'Save' : 'Add'} variant
|
||||
</Button>
|
||||
<StyledCancelButton
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</StyledCancelButton>
|
||||
</StyledButtonContainer>
|
||||
</StyledForm>
|
||||
</FormTemplate>
|
||||
</SidebarModal>
|
||||
);
|
||||
};
|
@ -0,0 +1,163 @@
|
||||
import { ChangeEvent, VFC } from 'react';
|
||||
import { IconButton, styled, TextField, Tooltip } from '@mui/material';
|
||||
import { Delete } from '@mui/icons-material';
|
||||
import { Autocomplete } from '@mui/material';
|
||||
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';
|
||||
import SelectMenu from 'component/common/select';
|
||||
|
||||
const StyledRow = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
rowGap: theme.spacing(1.5),
|
||||
marginBottom: theme.spacing(2),
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
flexDirection: 'column',
|
||||
'& > div, .MuiInputBase-root': {
|
||||
width: '100%',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledSelectMenu = styled(SelectMenu)(({ theme }) => ({
|
||||
minWidth: theme.spacing(20),
|
||||
marginRight: theme.spacing(10),
|
||||
}));
|
||||
|
||||
const StyledFieldColumn = styled('div')(({ theme }) => ({
|
||||
width: '100%',
|
||||
gap: theme.spacing(1.5),
|
||||
display: 'flex',
|
||||
}));
|
||||
|
||||
const StyledInputListField = styled(InputListField)(() => ({
|
||||
width: '100%',
|
||||
}));
|
||||
|
||||
const StyledTextField = styled(TextField)(() => ({
|
||||
width: '100%',
|
||||
}));
|
||||
|
||||
interface IOverrideConfigProps {
|
||||
overrides: IOverride[];
|
||||
overridesDispatch: OverridesDispatchType;
|
||||
}
|
||||
|
||||
export const OverrideConfig: VFC<IOverrideConfigProps> = ({
|
||||
overrides,
|
||||
overridesDispatch,
|
||||
}) => {
|
||||
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(
|
||||
({ name }) => name === override.contextName
|
||||
);
|
||||
const legalValues =
|
||||
definition?.legalValues?.map(({ value }) => value) || [];
|
||||
const filteredValues = override.values.filter(value =>
|
||||
legalValues.includes(value)
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledRow key={`override=${index}`}>
|
||||
<StyledSelectMenu
|
||||
id="override-context-name"
|
||||
name="contextName"
|
||||
label="Context Field"
|
||||
value={override.contextName}
|
||||
options={contextNames}
|
||||
onChange={e =>
|
||||
overridesDispatch({
|
||||
type: 'UPDATE_TYPE_AT',
|
||||
payload: [index, e.target.value],
|
||||
})
|
||||
}
|
||||
/>
|
||||
<StyledFieldColumn>
|
||||
<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 => (
|
||||
<StyledTextField
|
||||
{...params}
|
||||
variant="outlined"
|
||||
label="Legal values"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
elseShow={
|
||||
<StyledInputListField
|
||||
label="Values (v1, v2, ...)"
|
||||
name="values"
|
||||
placeholder=""
|
||||
values={override.values}
|
||||
updateValues={updateValues(index)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Tooltip title="Remove" arrow>
|
||||
<IconButton
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
overridesDispatch({
|
||||
type: 'REMOVE',
|
||||
payload: index,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</StyledFieldColumn>
|
||||
</StyledRow>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,154 @@
|
||||
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'] },
|
||||
]);
|
||||
});
|
||||
});
|
@ -0,0 +1,41 @@
|
||||
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, index) =>
|
||||
index === index1 ? { ...item, values } : item
|
||||
);
|
||||
case 'UPDATE_TYPE_AT':
|
||||
const [index2, contextName] = action.payload;
|
||||
return state.map((item, index) =>
|
||||
index === index2 ? { ...item, contextName } : item
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const useOverrides = (initialValue: IOverride[] = []) =>
|
||||
useReducer(overridesReducer, initialValue);
|
||||
|
||||
export type OverridesDispatchType = ReturnType<typeof useOverrides>[1];
|
@ -0,0 +1,169 @@
|
||||
import { Add, CloudCircle } from '@mui/icons-material';
|
||||
import { Button, Divider, styled } from '@mui/material';
|
||||
import { IFeatureEnvironment, IFeatureVariant } from 'interfaces/featureToggle';
|
||||
import { EnvironmentVariantsTable } from './EnvironmentVariantsTable/EnvironmentVariantsTable';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
|
||||
import { useMemo } from 'react';
|
||||
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
|
||||
|
||||
const StyledCard = styled('div')(({ theme }) => ({
|
||||
padding: theme.spacing(3),
|
||||
borderRadius: theme.shape.borderRadiusLarge,
|
||||
border: `1px solid ${theme.palette.dividerAlternative}`,
|
||||
'&:not(:last-child)': {
|
||||
marginBottom: theme.spacing(3),
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledHeader = styled('div')(() => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
'& > div': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledCloudCircle = styled(CloudCircle, {
|
||||
shouldForwardProp: prop => prop !== 'deprecated',
|
||||
})<{ deprecated?: boolean }>(({ theme, deprecated }) => ({
|
||||
color: deprecated
|
||||
? theme.palette.neutral.border
|
||||
: theme.palette.primary.main,
|
||||
}));
|
||||
|
||||
const StyledName = styled('span', {
|
||||
shouldForwardProp: prop => prop !== 'deprecated',
|
||||
})<{ deprecated?: boolean }>(({ theme, deprecated }) => ({
|
||||
color: deprecated
|
||||
? theme.palette.text.secondary
|
||||
: theme.palette.text.primary,
|
||||
marginLeft: theme.spacing(1.25),
|
||||
}));
|
||||
|
||||
const StyledDivider = styled(Divider)(({ theme }) => ({
|
||||
margin: theme.spacing(3, 0),
|
||||
}));
|
||||
|
||||
const StyledDescription = styled('p')(({ theme }) => ({
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
color: theme.palette.text.secondary,
|
||||
marginBottom: theme.spacing(1.5),
|
||||
}));
|
||||
|
||||
const StyledGeneralSelect = styled(GeneralSelect)(({ theme }) => ({
|
||||
minWidth: theme.spacing(20),
|
||||
}));
|
||||
|
||||
interface IEnvironmentVariantsCardProps {
|
||||
environment: IFeatureEnvironment;
|
||||
searchValue: string;
|
||||
onAddVariant: () => void;
|
||||
onEditVariant: (variant: IFeatureVariant) => void;
|
||||
onDeleteVariant: (variant: IFeatureVariant) => void;
|
||||
onUpdateStickiness: (variant: IFeatureVariant[]) => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const EnvironmentVariantsCard = ({
|
||||
environment,
|
||||
searchValue,
|
||||
onAddVariant,
|
||||
onEditVariant,
|
||||
onDeleteVariant,
|
||||
onUpdateStickiness,
|
||||
children,
|
||||
}: IEnvironmentVariantsCardProps) => {
|
||||
const { context } = useUnleashContext();
|
||||
|
||||
const variants = environment.variants ?? [];
|
||||
const stickiness = variants[0]?.stickiness || 'default';
|
||||
|
||||
const stickinessOptions = useMemo(
|
||||
() => [
|
||||
'default',
|
||||
...context.filter(c => c.stickiness).map(c => c.name),
|
||||
],
|
||||
[context]
|
||||
);
|
||||
|
||||
const options = stickinessOptions.map(c => ({ key: c, label: c }));
|
||||
if (!stickinessOptions.includes(stickiness)) {
|
||||
options.push({ key: stickiness, label: stickiness });
|
||||
}
|
||||
|
||||
const updateStickiness = async (stickiness: string) => {
|
||||
const newVariants = [...variants].map(variant => ({
|
||||
...variant,
|
||||
stickiness,
|
||||
}));
|
||||
onUpdateStickiness(newVariants);
|
||||
};
|
||||
|
||||
const onStickinessChange = (value: string) => {
|
||||
updateStickiness(value).catch(console.warn);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledCard>
|
||||
<StyledHeader>
|
||||
<div>
|
||||
<StyledCloudCircle deprecated={!environment.enabled} />
|
||||
<StyledName deprecated={!environment.enabled}>
|
||||
{environment.name}
|
||||
</StyledName>
|
||||
</div>
|
||||
{children}
|
||||
</StyledHeader>
|
||||
<ConditionallyRender
|
||||
condition={variants.length > 0}
|
||||
show={
|
||||
<>
|
||||
<EnvironmentVariantsTable
|
||||
environment={environment}
|
||||
searchValue={searchValue}
|
||||
onEditVariant={onEditVariant}
|
||||
onDeleteVariant={onDeleteVariant}
|
||||
/>
|
||||
<Button
|
||||
onClick={onAddVariant}
|
||||
variant="text"
|
||||
startIcon={<Add />}
|
||||
>
|
||||
add variant
|
||||
</Button>
|
||||
<ConditionallyRender
|
||||
condition={variants.length > 1}
|
||||
show={
|
||||
<>
|
||||
<StyledDivider />
|
||||
<p>Stickiness</p>
|
||||
<StyledDescription>
|
||||
By overriding the stickiness you can
|
||||
control which parameter is used to
|
||||
ensure consistent traffic allocation
|
||||
across variants.{' '}
|
||||
<a
|
||||
href="https://docs.getunleash.io/advanced/toggle_variants"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Read more
|
||||
</a>
|
||||
</StyledDescription>
|
||||
<StyledGeneralSelect
|
||||
options={options}
|
||||
value={stickiness}
|
||||
onChange={onStickinessChange}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</StyledCard>
|
||||
);
|
||||
};
|
@ -0,0 +1,184 @@
|
||||
import { styled, useMediaQuery, useTheme } from '@mui/material';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
|
||||
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
|
||||
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
|
||||
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||
import { calculateVariantWeight } from 'component/common/util';
|
||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||
import { useSearch } from 'hooks/useSearch';
|
||||
import {
|
||||
IFeatureEnvironment,
|
||||
IFeatureVariant,
|
||||
IOverride,
|
||||
IPayload,
|
||||
} from 'interfaces/featureToggle';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useFlexLayout, useSortBy, useTable } from 'react-table';
|
||||
import { sortTypes } from 'utils/sortTypes';
|
||||
import { PayloadCell } from './PayloadCell/PayloadCell';
|
||||
import { OverridesCell } from './OverridesCell/OverridesCell';
|
||||
import { VariantsActionCell } from './VariantsActionsCell/VariantsActionsCell';
|
||||
|
||||
const StyledTableContainer = styled('div')(({ theme }) => ({
|
||||
margin: theme.spacing(3, 0),
|
||||
}));
|
||||
|
||||
interface IEnvironmentVariantsTableProps {
|
||||
environment: IFeatureEnvironment;
|
||||
searchValue: string;
|
||||
onEditVariant: (variant: IFeatureVariant) => void;
|
||||
onDeleteVariant: (variant: IFeatureVariant) => void;
|
||||
}
|
||||
|
||||
export const EnvironmentVariantsTable = ({
|
||||
environment,
|
||||
searchValue,
|
||||
onEditVariant,
|
||||
onDeleteVariant,
|
||||
}: IEnvironmentVariantsTableProps) => {
|
||||
const projectId = useRequiredPathParam('projectId');
|
||||
|
||||
const theme = useTheme();
|
||||
const isMediumScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const isLargeScreen = useMediaQuery(theme.breakpoints.down('lg'));
|
||||
|
||||
const variants = environment.variants ?? [];
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
Header: 'Name',
|
||||
accessor: 'name',
|
||||
Cell: HighlightCell,
|
||||
sortType: 'alphanumeric',
|
||||
minWidth: 100,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
Header: 'Payload',
|
||||
accessor: 'payload',
|
||||
Cell: PayloadCell,
|
||||
disableSortBy: true,
|
||||
searchable: true,
|
||||
filterParsing: (value: IPayload) => value?.value,
|
||||
},
|
||||
{
|
||||
Header: 'Overrides',
|
||||
accessor: 'overrides',
|
||||
Cell: OverridesCell,
|
||||
disableSortBy: true,
|
||||
searchable: true,
|
||||
filterParsing: (value: IOverride[]) =>
|
||||
value
|
||||
?.map(
|
||||
({ contextName, values }) =>
|
||||
`${contextName}:${values.join()}`
|
||||
)
|
||||
.join('\n') || '',
|
||||
},
|
||||
{
|
||||
Header: 'Weight',
|
||||
accessor: 'weight',
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { name, weight },
|
||||
},
|
||||
}: any) => {
|
||||
return (
|
||||
<TextCell data-testid={`VARIANT_WEIGHT_${name}`}>
|
||||
{calculateVariantWeight(weight)} %
|
||||
</TextCell>
|
||||
);
|
||||
},
|
||||
sortType: 'number',
|
||||
},
|
||||
{
|
||||
Header: 'Type',
|
||||
accessor: 'weightType',
|
||||
Cell: TextCell,
|
||||
sortType: 'alphanumeric',
|
||||
},
|
||||
{
|
||||
Header: 'Actions',
|
||||
id: 'Actions',
|
||||
align: 'center',
|
||||
Cell: ({
|
||||
row: { original },
|
||||
}: {
|
||||
row: { original: IFeatureVariant };
|
||||
}) => (
|
||||
<VariantsActionCell
|
||||
variant={original}
|
||||
projectId={projectId}
|
||||
editVariant={onEditVariant}
|
||||
deleteVariant={onDeleteVariant}
|
||||
/>
|
||||
),
|
||||
disableSortBy: true,
|
||||
},
|
||||
],
|
||||
[projectId, variants, environment]
|
||||
);
|
||||
|
||||
const initialState = useMemo(
|
||||
() => ({
|
||||
sortBy: [{ id: 'name', desc: false }],
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const { data, getSearchText } = useSearch(columns, searchValue, variants);
|
||||
|
||||
const { headerGroups, rows, prepareRow, setHiddenColumns } = useTable(
|
||||
{
|
||||
columns: columns as any[],
|
||||
data,
|
||||
initialState,
|
||||
sortTypes,
|
||||
autoResetSortBy: false,
|
||||
disableSortRemove: true,
|
||||
disableMultiSort: true,
|
||||
},
|
||||
useSortBy,
|
||||
useFlexLayout
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const hiddenColumns = [];
|
||||
if (isLargeScreen) {
|
||||
hiddenColumns.push('weightType');
|
||||
}
|
||||
if (isMediumScreen) {
|
||||
hiddenColumns.push('payload', 'overrides');
|
||||
}
|
||||
setHiddenColumns(hiddenColumns);
|
||||
}, [setHiddenColumns, isMediumScreen, isLargeScreen]);
|
||||
|
||||
return (
|
||||
<StyledTableContainer>
|
||||
<SearchHighlightProvider value={getSearchText(searchValue)}>
|
||||
<VirtualizedTable
|
||||
rows={rows}
|
||||
headerGroups={headerGroups}
|
||||
prepareRow={prepareRow}
|
||||
/>
|
||||
</SearchHighlightProvider>
|
||||
<ConditionallyRender
|
||||
condition={rows.length === 0}
|
||||
show={
|
||||
<ConditionallyRender
|
||||
condition={searchValue?.length > 0}
|
||||
show={
|
||||
<TablePlaceholder>
|
||||
No variants found matching “
|
||||
{searchValue}
|
||||
”
|
||||
</TablePlaceholder>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</StyledTableContainer>
|
||||
);
|
||||
};
|
@ -0,0 +1,63 @@
|
||||
import { Link, styled, Typography } from '@mui/material';
|
||||
import { Highlighter } from 'component/common/Highlighter/Highlighter';
|
||||
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
|
||||
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
|
||||
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||
import { IOverride } from 'interfaces/featureToggle';
|
||||
|
||||
const StyledItem = styled(Typography)(({ theme }) => ({
|
||||
fontSize: theme.fontSizes.smallerBody,
|
||||
}));
|
||||
|
||||
const StyledLink = styled(Link, {
|
||||
shouldForwardProp: prop => prop !== 'highlighted',
|
||||
})<{ highlighted?: boolean }>(({ theme, highlighted }) => ({
|
||||
backgroundColor: highlighted ? theme.palette.highlight : 'transparent',
|
||||
}));
|
||||
|
||||
interface IOverridesCellProps {
|
||||
value?: IOverride[];
|
||||
}
|
||||
|
||||
export const OverridesCell = ({ value: overrides }: IOverridesCellProps) => {
|
||||
const { searchQuery } = useSearchHighlightContext();
|
||||
|
||||
if (!overrides || overrides.length === 0) return <TextCell />;
|
||||
|
||||
const overrideToString = (override: IOverride) =>
|
||||
`${override.contextName}:${override.values.join()}`;
|
||||
|
||||
return (
|
||||
<TextCell>
|
||||
<HtmlTooltip
|
||||
title={
|
||||
<>
|
||||
{overrides.map((override, index) => (
|
||||
<StyledItem key={override.contextName + index}>
|
||||
<Highlighter search={searchQuery}>
|
||||
{overrideToString(override)}
|
||||
</Highlighter>
|
||||
</StyledItem>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StyledLink
|
||||
underline="always"
|
||||
highlighted={
|
||||
searchQuery.length > 0 &&
|
||||
overrides
|
||||
?.map(overrideToString)
|
||||
.join('\n')
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase())
|
||||
}
|
||||
>
|
||||
{overrides.length === 1
|
||||
? '1 override'
|
||||
: `${overrides.length} overrides`}
|
||||
</StyledLink>
|
||||
</HtmlTooltip>
|
||||
</TextCell>
|
||||
);
|
||||
};
|
@ -0,0 +1,63 @@
|
||||
import { Link, styled, Typography } from '@mui/material';
|
||||
import { Highlighter } from 'component/common/Highlighter/Highlighter';
|
||||
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
|
||||
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
|
||||
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||
import { IPayload } from 'interfaces/featureToggle';
|
||||
|
||||
const StyledItem = styled(Typography)(({ theme }) => ({
|
||||
fontSize: theme.fontSizes.smallerBody,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}));
|
||||
|
||||
const StyledLink = styled(Link, {
|
||||
shouldForwardProp: prop => prop !== 'highlighted',
|
||||
})<{ highlighted?: boolean }>(({ theme, highlighted }) => ({
|
||||
backgroundColor: highlighted ? theme.palette.highlight : 'transparent',
|
||||
}));
|
||||
|
||||
interface IPayloadCellProps {
|
||||
value?: IPayload;
|
||||
}
|
||||
|
||||
export const PayloadCell = ({ value: payload }: IPayloadCellProps) => {
|
||||
const { searchQuery } = useSearchHighlightContext();
|
||||
|
||||
if (!payload) return <TextCell />;
|
||||
|
||||
if (payload.type === 'string' && payload.value.length < 20) {
|
||||
return (
|
||||
<TextCell>
|
||||
<Highlighter search={searchQuery}>{payload.value}</Highlighter>
|
||||
</TextCell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextCell>
|
||||
<HtmlTooltip
|
||||
title={
|
||||
<>
|
||||
<StyledItem>
|
||||
<Highlighter search={searchQuery}>
|
||||
{payload.value}
|
||||
</Highlighter>
|
||||
</StyledItem>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StyledLink
|
||||
underline="always"
|
||||
highlighted={
|
||||
searchQuery.length > 0 &&
|
||||
payload.value
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase())
|
||||
}
|
||||
>
|
||||
{payload.type}
|
||||
</StyledLink>
|
||||
</HtmlTooltip>
|
||||
</TextCell>
|
||||
);
|
||||
};
|
@ -0,0 +1,48 @@
|
||||
import { Edit, Delete } from '@mui/icons-material';
|
||||
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
|
||||
import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
|
||||
import { UPDATE_FEATURE_VARIANTS } from 'component/providers/AccessProvider/permissions';
|
||||
import { IFeatureVariant } from 'interfaces/featureToggle';
|
||||
|
||||
interface IVarintsActionCellProps {
|
||||
projectId: string;
|
||||
variant: IFeatureVariant;
|
||||
editVariant: (variant: IFeatureVariant) => void;
|
||||
deleteVariant: (variant: IFeatureVariant) => void;
|
||||
}
|
||||
|
||||
export const VariantsActionCell = ({
|
||||
projectId,
|
||||
variant,
|
||||
editVariant,
|
||||
deleteVariant,
|
||||
}: IVarintsActionCellProps) => {
|
||||
return (
|
||||
<ActionCell>
|
||||
<PermissionIconButton
|
||||
size="large"
|
||||
data-testid={`VARIANT_EDIT_BUTTON_${variant.name}`}
|
||||
permission={UPDATE_FEATURE_VARIANTS}
|
||||
projectId={projectId}
|
||||
onClick={() => editVariant(variant)}
|
||||
tooltipProps={{
|
||||
title: 'Edit variant',
|
||||
}}
|
||||
>
|
||||
<Edit />
|
||||
</PermissionIconButton>
|
||||
<PermissionIconButton
|
||||
size="large"
|
||||
permission={UPDATE_FEATURE_VARIANTS}
|
||||
data-testid={`VARIANT_DELETE_BUTTON_${variant.name}`}
|
||||
projectId={projectId}
|
||||
onClick={() => deleteVariant(variant)}
|
||||
tooltipProps={{
|
||||
title: 'Delete variant',
|
||||
}}
|
||||
>
|
||||
<Delete />
|
||||
</PermissionIconButton>
|
||||
</ActionCell>
|
||||
);
|
||||
};
|
@ -0,0 +1,82 @@
|
||||
import { ListItemText, Menu, MenuItem, styled } from '@mui/material';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
|
||||
import { IFeatureEnvironment } from 'interfaces/featureToggle';
|
||||
import { useState } from 'react';
|
||||
|
||||
const StyledListItemText = styled(ListItemText)(({ theme }) => ({
|
||||
'& span': {
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
},
|
||||
}));
|
||||
|
||||
interface IEnvironmentVariantsCopyFromProps {
|
||||
environment: IFeatureEnvironment;
|
||||
permission: string;
|
||||
projectId: string;
|
||||
onCopyVariantsFrom: (
|
||||
fromEnvironment: IFeatureEnvironment,
|
||||
toEnvironment: IFeatureEnvironment
|
||||
) => void;
|
||||
otherEnvsWithVariants: IFeatureEnvironment[];
|
||||
}
|
||||
|
||||
export const EnvironmentVariantsCopyFrom = ({
|
||||
environment,
|
||||
permission,
|
||||
projectId,
|
||||
onCopyVariantsFrom,
|
||||
otherEnvsWithVariants,
|
||||
}: IEnvironmentVariantsCopyFromProps) => {
|
||||
const [copyFromAnchorEl, setCopyFromAnchorEl] =
|
||||
useState<null | HTMLElement>(null);
|
||||
const copyFromOpen = Boolean(copyFromAnchorEl);
|
||||
|
||||
return (
|
||||
<ConditionallyRender
|
||||
condition={otherEnvsWithVariants.length > 0}
|
||||
show={
|
||||
<>
|
||||
<PermissionButton
|
||||
onClick={e => {
|
||||
setCopyFromAnchorEl(e.currentTarget);
|
||||
}}
|
||||
id={`copy-from-menu-${environment.name}`}
|
||||
aria-controls={copyFromOpen ? 'basic-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={copyFromOpen ? 'true' : undefined}
|
||||
variant="outlined"
|
||||
permission={permission}
|
||||
projectId={projectId}
|
||||
>
|
||||
Copy variants from
|
||||
</PermissionButton>
|
||||
<Menu
|
||||
anchorEl={copyFromAnchorEl}
|
||||
open={copyFromOpen}
|
||||
onClose={() => setCopyFromAnchorEl(null)}
|
||||
MenuListProps={{
|
||||
'aria-labelledby': `copy-from-menu-${environment.name}`,
|
||||
}}
|
||||
>
|
||||
{otherEnvsWithVariants.map(otherEnvironment => (
|
||||
<MenuItem
|
||||
key={otherEnvironment.name}
|
||||
onClick={() =>
|
||||
onCopyVariantsFrom(
|
||||
otherEnvironment,
|
||||
environment
|
||||
)
|
||||
}
|
||||
>
|
||||
<StyledListItemText>
|
||||
{`Copy from ${otherEnvironment.name}`}
|
||||
</StyledListItemText>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,291 @@
|
||||
import * as jsonpatch from 'fast-json-patch';
|
||||
|
||||
import { Alert, styled, useMediaQuery, useTheme } from '@mui/material';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
|
||||
import { Search } from 'component/common/Search/Search';
|
||||
import { updateWeight } from 'component/common/util';
|
||||
import { UPDATE_FEATURE_VARIANTS } from 'component/providers/AccessProvider/permissions';
|
||||
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
|
||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||
import { IFeatureEnvironment, IFeatureVariant } from 'interfaces/featureToggle';
|
||||
import { useState } from 'react';
|
||||
import { EnvironmentVariantModal } from './EnvironmentVariantModal/EnvironmentVariantModal';
|
||||
import { EnvironmentVariantsCard } from './EnvironmentVariantsCard/EnvironmentVariantsCard';
|
||||
import { VariantDeleteDialog } from './VariantDeleteDialog/VariantDeleteDialog';
|
||||
import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
|
||||
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||
import useToast from 'hooks/useToast';
|
||||
import { EnvironmentVariantsCopyFrom } from './EnvironmentVariantsCopyFrom/EnvironmentVariantsCopyFrom';
|
||||
|
||||
const StyledAlert = styled(Alert)(({ theme }) => ({
|
||||
marginBottom: theme.spacing(4),
|
||||
'& code': {
|
||||
fontWeight: theme.fontWeight.bold,
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledButtonContainer = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
gap: theme.spacing(1.5),
|
||||
}));
|
||||
|
||||
export const FeatureEnvironmentVariants = () => {
|
||||
const { setToastData, setToastApiError } = useToast();
|
||||
const theme = useTheme();
|
||||
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
const projectId = useRequiredPathParam('projectId');
|
||||
const featureId = useRequiredPathParam('featureId');
|
||||
const { feature, refetchFeature, loading } = useFeature(
|
||||
projectId,
|
||||
featureId
|
||||
);
|
||||
const { patchFeatureEnvironmentVariants } = useFeatureApi();
|
||||
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [selectedEnvironment, setSelectedEnvironment] =
|
||||
useState<IFeatureEnvironment>();
|
||||
const [selectedVariant, setSelectedVariant] = useState<IFeatureVariant>();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
const createPatch = (
|
||||
variants: IFeatureVariant[],
|
||||
newVariants: IFeatureVariant[]
|
||||
) => {
|
||||
return jsonpatch.compare(variants, newVariants);
|
||||
};
|
||||
|
||||
const getApiPayload = (
|
||||
variants: IFeatureVariant[],
|
||||
newVariants: IFeatureVariant[]
|
||||
): {
|
||||
patch: jsonpatch.Operation[];
|
||||
error?: string;
|
||||
} => {
|
||||
try {
|
||||
const updatedNewVariants = updateWeight(newVariants, 1000);
|
||||
return { patch: createPatch(variants, updatedNewVariants) };
|
||||
} catch (error: unknown) {
|
||||
return { patch: [], error: formatUnknownError(error) };
|
||||
}
|
||||
};
|
||||
|
||||
const updateVariants = async (
|
||||
environment: IFeatureEnvironment,
|
||||
variants: IFeatureVariant[]
|
||||
) => {
|
||||
const environmentVariants = environment.variants ?? [];
|
||||
const { patch } = getApiPayload(environmentVariants, variants);
|
||||
|
||||
if (patch.length === 0) return;
|
||||
|
||||
await patchFeatureEnvironmentVariants(
|
||||
projectId,
|
||||
featureId,
|
||||
environment.name,
|
||||
patch
|
||||
);
|
||||
refetchFeature();
|
||||
};
|
||||
|
||||
const addVariant = (environment: IFeatureEnvironment) => {
|
||||
setSelectedEnvironment(environment);
|
||||
setSelectedVariant(undefined);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const editVariant = (
|
||||
environment: IFeatureEnvironment,
|
||||
variant: IFeatureVariant
|
||||
) => {
|
||||
setSelectedEnvironment(environment);
|
||||
setSelectedVariant(variant);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const deleteVariant = (
|
||||
environment: IFeatureEnvironment,
|
||||
variant: IFeatureVariant
|
||||
) => {
|
||||
setSelectedEnvironment(environment);
|
||||
setSelectedVariant(variant);
|
||||
setDeleteOpen(true);
|
||||
};
|
||||
|
||||
const onDeleteConfirm = async () => {
|
||||
if (selectedEnvironment && selectedVariant) {
|
||||
const variants = selectedEnvironment.variants ?? [];
|
||||
|
||||
const updatedVariants = variants.filter(
|
||||
({ name }) => name !== selectedVariant.name
|
||||
);
|
||||
|
||||
try {
|
||||
await updateVariants(selectedEnvironment, updatedVariants);
|
||||
setDeleteOpen(false);
|
||||
setToastData({
|
||||
title: `Variant deleted successfully`,
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
setToastApiError(formatUnknownError(error));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onVariantConfirm = async (updatedVariants: IFeatureVariant[]) => {
|
||||
if (selectedEnvironment) {
|
||||
try {
|
||||
await updateVariants(selectedEnvironment, updatedVariants);
|
||||
setModalOpen(false);
|
||||
setToastData({
|
||||
title: `Variant ${
|
||||
selectedVariant ? 'updated' : 'added'
|
||||
} successfully`,
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
setToastApiError(formatUnknownError(error));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onCopyVariantsFrom = async (
|
||||
fromEnvironment: IFeatureEnvironment,
|
||||
toEnvironment: IFeatureEnvironment
|
||||
) => {
|
||||
try {
|
||||
const variants = fromEnvironment.variants ?? [];
|
||||
await updateVariants(toEnvironment, variants);
|
||||
setToastData({
|
||||
title: 'Variants copied successfully',
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
setToastApiError(formatUnknownError(error));
|
||||
}
|
||||
};
|
||||
|
||||
const onUpdateStickiness = async (
|
||||
environment: IFeatureEnvironment,
|
||||
updatedVariants: IFeatureVariant[]
|
||||
) => {
|
||||
try {
|
||||
await updateVariants(environment, updatedVariants);
|
||||
setToastData({
|
||||
title: 'Variant stickiness updated successfully',
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
setToastApiError(formatUnknownError(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContent
|
||||
isLoading={loading}
|
||||
header={
|
||||
<PageHeader
|
||||
title="Variants"
|
||||
actions={
|
||||
<ConditionallyRender
|
||||
condition={!isSmallScreen}
|
||||
show={
|
||||
<>
|
||||
<Search
|
||||
initialValue={searchValue}
|
||||
onChange={setSearchValue}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ConditionallyRender
|
||||
condition={isSmallScreen}
|
||||
show={
|
||||
<Search
|
||||
initialValue={searchValue}
|
||||
onChange={setSearchValue}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PageHeader>
|
||||
}
|
||||
>
|
||||
<StyledAlert severity="info">
|
||||
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>getVariant()</code> method in
|
||||
the Client SDK.
|
||||
</StyledAlert>
|
||||
{feature.environments.map(environment => {
|
||||
const otherEnvsWithVariants = feature.environments.filter(
|
||||
({ name, variants }) =>
|
||||
name !== environment.name && variants?.length
|
||||
);
|
||||
|
||||
return (
|
||||
<EnvironmentVariantsCard
|
||||
key={environment.name}
|
||||
environment={environment}
|
||||
searchValue={searchValue}
|
||||
onAddVariant={() => addVariant(environment)}
|
||||
onEditVariant={(variant: IFeatureVariant) =>
|
||||
editVariant(environment, variant)
|
||||
}
|
||||
onDeleteVariant={(variant: IFeatureVariant) =>
|
||||
deleteVariant(environment, variant)
|
||||
}
|
||||
onUpdateStickiness={(variants: IFeatureVariant[]) =>
|
||||
onUpdateStickiness(environment, variants)
|
||||
}
|
||||
>
|
||||
<ConditionallyRender
|
||||
condition={environment.variants?.length === 0}
|
||||
show={
|
||||
<StyledButtonContainer>
|
||||
<PermissionButton
|
||||
onClick={() => addVariant(environment)}
|
||||
variant="outlined"
|
||||
permission={UPDATE_FEATURE_VARIANTS}
|
||||
projectId={projectId}
|
||||
>
|
||||
Add variant
|
||||
</PermissionButton>
|
||||
<EnvironmentVariantsCopyFrom
|
||||
environment={environment}
|
||||
permission={UPDATE_FEATURE_VARIANTS}
|
||||
projectId={projectId}
|
||||
onCopyVariantsFrom={onCopyVariantsFrom}
|
||||
otherEnvsWithVariants={
|
||||
otherEnvsWithVariants
|
||||
}
|
||||
/>
|
||||
</StyledButtonContainer>
|
||||
}
|
||||
/>
|
||||
</EnvironmentVariantsCard>
|
||||
);
|
||||
})}
|
||||
<EnvironmentVariantModal
|
||||
environment={selectedEnvironment}
|
||||
variant={selectedVariant}
|
||||
open={modalOpen}
|
||||
setOpen={setModalOpen}
|
||||
getApiPayload={getApiPayload}
|
||||
onConfirm={onVariantConfirm}
|
||||
/>
|
||||
<VariantDeleteDialog
|
||||
variant={selectedVariant}
|
||||
open={deleteOpen}
|
||||
setOpen={setDeleteOpen}
|
||||
onConfirm={onDeleteConfirm}
|
||||
/>
|
||||
</PageContent>
|
||||
);
|
||||
};
|
@ -0,0 +1,42 @@
|
||||
import { Alert, styled } from '@mui/material';
|
||||
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||
import { IFeatureVariant } from 'interfaces/featureToggle';
|
||||
|
||||
const StyledLabel = styled('p')(({ theme }) => ({
|
||||
marginTop: theme.spacing(3),
|
||||
}));
|
||||
|
||||
interface IVariantDeleteDialogProps {
|
||||
variant?: IFeatureVariant;
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export const VariantDeleteDialog = ({
|
||||
variant,
|
||||
open,
|
||||
setOpen,
|
||||
onConfirm,
|
||||
}: IVariantDeleteDialogProps) => {
|
||||
return (
|
||||
<Dialogue
|
||||
title="Delete variant?"
|
||||
open={open}
|
||||
primaryButtonText="Delete variant"
|
||||
secondaryButtonText="Close"
|
||||
onClick={onConfirm}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Alert severity="error">
|
||||
Deleting this variant will change which variant users receive.
|
||||
</Alert>
|
||||
<StyledLabel>
|
||||
You are about to delete variant:{' '}
|
||||
<strong>{variant?.name}</strong>
|
||||
</StyledLabel>
|
||||
</Dialogue>
|
||||
);
|
||||
};
|
@ -1,9 +1,17 @@
|
||||
import { FeatureVariantsList } from './FeatureVariantsList/FeatureVariantsList';
|
||||
import { usePageTitle } from 'hooks/usePageTitle';
|
||||
import { FeatureEnvironmentVariants } from './FeatureEnvironmentVariants/FeatureEnvironmentVariants';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
|
||||
const FeatureVariants = () => {
|
||||
usePageTitle('Variants');
|
||||
|
||||
const { uiConfig } = useUiConfig();
|
||||
|
||||
if (uiConfig.flags.variantsPerEnvironment) {
|
||||
return <FeatureEnvironmentVariants />;
|
||||
}
|
||||
|
||||
return <FeatureVariantsList />;
|
||||
};
|
||||
|
||||
|
@ -202,6 +202,26 @@ const useFeatureApi = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const patchFeatureEnvironmentVariants = async (
|
||||
projectId: string,
|
||||
featureId: string,
|
||||
environmentName: string,
|
||||
patchPayload: Operation[]
|
||||
) => {
|
||||
const path = `api/admin/projects/${projectId}/features/${featureId}/environments/${environmentName}/variants`;
|
||||
const req = createRequest(path, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patchPayload),
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await makeRequest(req.caller, req.id);
|
||||
return res;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const cloneFeatureToggle = async (
|
||||
projectId: string,
|
||||
featureId: string,
|
||||
@ -235,6 +255,7 @@ const useFeatureApi = () => {
|
||||
archiveFeatureToggle,
|
||||
patchFeatureToggle,
|
||||
patchFeatureVariants,
|
||||
patchFeatureEnvironmentVariants,
|
||||
cloneFeatureToggle,
|
||||
loading,
|
||||
};
|
||||
|
@ -1,9 +1,10 @@
|
||||
import useSWR, { SWRConfiguration } from 'swr';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { emptyFeature } from './emptyFeature';
|
||||
import handleErrorResponses from '../httpErrorResponseHandler';
|
||||
import { formatApiPath } from 'utils/formatPath';
|
||||
import { IFeatureToggle } from 'interfaces/featureToggle';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
|
||||
export interface IUseFeatureOutput {
|
||||
feature: IFeatureToggle;
|
||||
@ -25,9 +26,14 @@ export const useFeature = (
|
||||
): IUseFeatureOutput => {
|
||||
const path = formatFeatureApiPath(projectId, featureId);
|
||||
|
||||
const { uiConfig } = useUiConfig();
|
||||
const {
|
||||
flags: { variantsPerEnvironment },
|
||||
} = uiConfig;
|
||||
|
||||
const { data, error, mutate } = useSWR<IFeatureResponse>(
|
||||
['useFeature', path],
|
||||
() => featureFetcher(path),
|
||||
() => featureFetcher(path, variantsPerEnvironment),
|
||||
options
|
||||
);
|
||||
|
||||
@ -35,6 +41,10 @@ export const useFeature = (
|
||||
mutate().catch(console.warn);
|
||||
}, [mutate]);
|
||||
|
||||
useEffect(() => {
|
||||
mutate();
|
||||
}, [mutate, variantsPerEnvironment]);
|
||||
|
||||
return {
|
||||
feature: data?.body || emptyFeature,
|
||||
refetchFeature,
|
||||
@ -45,9 +55,14 @@ export const useFeature = (
|
||||
};
|
||||
|
||||
export const featureFetcher = async (
|
||||
path: string
|
||||
path: string,
|
||||
variantsPerEnvironment?: boolean
|
||||
): Promise<IFeatureResponse> => {
|
||||
const res = await fetch(path);
|
||||
const variantEnvironments = variantsPerEnvironment
|
||||
? '?variantEnvironments=true'
|
||||
: '';
|
||||
|
||||
const res = await fetch(path + variantEnvironments);
|
||||
|
||||
if (res.status === 404) {
|
||||
return { status: 404 };
|
||||
|
@ -37,6 +37,7 @@ export interface IFeatureEnvironment {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
strategies: IFeatureStrategy[];
|
||||
variants?: IFeatureVariant[];
|
||||
}
|
||||
|
||||
export interface IFeatureVariant {
|
||||
|
@ -43,6 +43,7 @@ export interface IFlags {
|
||||
syncSSOGroups?: boolean;
|
||||
changeRequests?: boolean;
|
||||
cloneEnvironment?: boolean;
|
||||
variantsPerEnvironment?: boolean;
|
||||
}
|
||||
|
||||
export interface IVersionInfo {
|
||||
|
@ -76,6 +76,7 @@ exports[`should create default config 1`] = `
|
||||
"responseTimeWithAppName": false,
|
||||
"syncSSOGroups": false,
|
||||
"toggleTagFiltering": false,
|
||||
"variantsPerEnvironment": false,
|
||||
},
|
||||
},
|
||||
"flagResolver": FlagResolver {
|
||||
@ -90,6 +91,7 @@ exports[`should create default config 1`] = `
|
||||
"responseTimeWithAppName": false,
|
||||
"syncSSOGroups": false,
|
||||
"toggleTagFiltering": false,
|
||||
"variantsPerEnvironment": false,
|
||||
},
|
||||
"externalResolver": {
|
||||
"isEnabled": [Function],
|
||||
|
@ -38,6 +38,10 @@ export const defaultExperimentalOptions = {
|
||||
process.env.UNLEASH_EXPERIMENTAL_TOGGLE_TAG_FILTERING,
|
||||
false,
|
||||
),
|
||||
variantsPerEnvironment: parseEnvVarBoolean(
|
||||
process.env.UNLEASH_EXPERIMENTAL_VARIANTS_PER_ENVIRONMENT,
|
||||
false,
|
||||
),
|
||||
},
|
||||
externalResolver: { isEnabled: (): boolean => false },
|
||||
};
|
||||
@ -53,6 +57,7 @@ export interface IExperimentalOptions {
|
||||
syncSSOGroups?: boolean;
|
||||
changeRequests?: boolean;
|
||||
cloneEnvironment?: boolean;
|
||||
variantsPerEnvironment?: boolean;
|
||||
};
|
||||
externalResolver: IExternalFlagResolver;
|
||||
}
|
||||
|
@ -42,6 +42,7 @@ process.nextTick(async () => {
|
||||
changeRequests: true,
|
||||
cloneEnvironment: true,
|
||||
toggleTagFiltering: true,
|
||||
variantsPerEnvironment: true,
|
||||
},
|
||||
},
|
||||
authentication: {
|
||||
|
@ -30,6 +30,7 @@ export function createTestConfig(config?: IUnleashOptions): IUnleashConfig {
|
||||
syncSSOGroups: true,
|
||||
changeRequests: true,
|
||||
cloneEnvironment: true,
|
||||
variantsPerEnvironment: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user