From 93bd9d869a9f01fdd77150880f741668a73dc704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Fri, 18 Nov 2022 11:43:24 +0000 Subject: [PATCH] Variants per environment (frontend) (#2453) ![image](https://user-images.githubusercontent.com/14320932/202286759-b9c30228-59cc-4c58-a7b0-3c6c3d0ecba6.png) ## 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. --- .../common/DateTimePicker/DateTimePicker.tsx | 1 - .../component/common/Input/Input.styles.ts | 2 +- frontend/src/component/common/Input/Input.tsx | 2 - .../EnvironmentCloneModal.tsx | 8 +- .../EnvironmentVariantModal.tsx | 506 ++++++++++++++++++ .../VariantOverrides/VariantOverrides.tsx | 163 ++++++ .../VariantOverrides/useOverrides.test.ts | 154 ++++++ .../VariantOverrides/useOverrides.ts | 41 ++ .../EnvironmentVariantsCard.tsx | 169 ++++++ .../EnvironmentVariantsTable.tsx | 184 +++++++ .../OverridesCell/OverridesCell.tsx | 63 +++ .../PayloadCell/PayloadCell.tsx | 63 +++ .../VariantsActionsCell.tsx | 48 ++ .../EnvironmentVariantsCopyFrom.tsx | 82 +++ .../FeatureEnvironmentVariants.tsx | 291 ++++++++++ .../VariantDeleteDialog.tsx | 42 ++ .../FeatureVariants/FeatureVariants.tsx | 8 + .../actions/useFeatureApi/useFeatureApi.ts | 21 + .../api/getters/useFeature/useFeature.ts | 23 +- frontend/src/interfaces/featureToggle.ts | 1 + frontend/src/interfaces/uiConfig.ts | 1 + .../__snapshots__/create-config.test.ts.snap | 2 + src/lib/types/experimental.ts | 5 + src/server-dev.ts | 1 + src/test/config/test-config.ts | 1 + 25 files changed, 1870 insertions(+), 12 deletions(-) create mode 100644 frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/EnvironmentVariantModal.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/VariantOverrides.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/useOverrides.test.ts create mode 100644 frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/useOverrides.ts create mode 100644 frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsCard.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/EnvironmentVariantsTable.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/OverridesCell/OverridesCell.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/PayloadCell/PayloadCell.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/VariantsActionsCell/VariantsActionsCell.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCopyFrom/EnvironmentVariantsCopyFrom.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/FeatureEnvironmentVariants.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/VariantDeleteDialog/VariantDeleteDialog.tsx diff --git a/frontend/src/component/common/DateTimePicker/DateTimePicker.tsx b/frontend/src/component/common/DateTimePicker/DateTimePicker.tsx index 92330d4f78..6edf90e762 100644 --- a/frontend/src/component/common/DateTimePicker/DateTimePicker.tsx +++ b/frontend/src/component/common/DateTimePicker/DateTimePicker.tsx @@ -33,7 +33,6 @@ export const DateTimePicker = ({ max, value, onChange, - InputProps, ...rest }: IDateTimePickerProps) => { const getDate = type === 'datetime' ? formatDateTime : formatDate; diff --git a/frontend/src/component/common/Input/Input.styles.ts b/frontend/src/component/common/Input/Input.styles.ts index 12295699d6..ac93565a64 100644 --- a/frontend/src/component/common/Input/Input.styles.ts +++ b/frontend/src/component/common/Input/Input.styles.ts @@ -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%', diff --git a/frontend/src/component/common/Input/Input.tsx b/frontend/src/component/common/Input/Input.tsx index 59ccf9cfc2..5bc94add91 100644 --- a/frontend/src/component/common/Input/Input.tsx +++ b/frontend/src/component/common/Input/Input.tsx @@ -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 { @@ -26,7 +25,6 @@ const Input = ({ className, value, onChange, - InputProps, ...rest }: IInputProps) => { const { classes: styles } = useStyles(); diff --git a/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentCloneModal/EnvironmentCloneModal.tsx b/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentCloneModal/EnvironmentCloneModal.tsx index bdb732a0f5..8a0e253396 100644 --- a/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentCloneModal/EnvironmentCloneModal.tsx +++ b/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentCloneModal/EnvironmentCloneModal.tsx @@ -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>; @@ -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.LATER); - const [errors, setErrors] = useState({}); + const [errors, setErrors] = useState({}); const clearError = (field: ErrorField) => { setErrors(errors => ({ ...errors, [field]: undefined })); diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/EnvironmentVariantModal.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/EnvironmentVariantModal.tsx new file mode 100644 index 0000000000..2bd9ef7a56 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/EnvironmentVariantModal.tsx @@ -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>; + 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(EMPTY_PAYLOAD); + const [overrides, overridesDispatch] = useOverrides([]); + const { context } = useUnleashContext(); + + const [errors, setErrors] = useState({}); + + 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) => { + 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 ( + { + setOpen(false); + }} + label={editing ? 'Edit variant' : 'Add variant'} + > + + + + + {environment?.name} + + + +
+ + Variant name + + + This will be used to identify the variant in your + code + + onSetName(e.target.value)} + disabled={editing} + required + /> + + setCustomPercentage( + e.target.checked + ) + } + /> + } + /> + } + /> + + onSetPercentage(e.target.value) + } + required={customPercentage} + disabled={!customPercentage} + aria-valuemin={0} + aria-valuemax={100} + InputProps={{ + endAdornment: ( + + % + + ), + }} + /> + } + /> + + Payload + + + + { + clearError(ErrorField.PAYLOAD); + setPayload(payload => ({ + ...payload, + type: e.target.value, + })); + }} + /> + { + 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} + /> + + + Overrides + + + + +
+ + + + + { + setOpen(false); + }} + > + Cancel + + +
+
+
+ ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/VariantOverrides.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/VariantOverrides.tsx new file mode 100644 index 0000000000..34dab01973 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/VariantOverrides.tsx @@ -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 = ({ + 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, 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 ( + + + overridesDispatch({ + type: 'UPDATE_TYPE_AT', + payload: [index, e.target.value], + }) + } + /> + + 0 + )} + show={ + { + return option === value; + }} + options={legalValues} + onChange={updateSelectValues(index)} + getOptionLabel={option => option} + value={filteredValues} + style={{ width: '100%' }} + filterSelectedOptions + size="small" + renderInput={params => ( + + )} + /> + } + elseShow={ + + } + /> + + { + event.preventDefault(); + overridesDispatch({ + type: 'REMOVE', + payload: index, + }); + }} + > + + + + + + ); + })} + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/useOverrides.test.ts b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/useOverrides.test.ts new file mode 100644 index 0000000000..1723768782 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/useOverrides.test.ts @@ -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'] }, + ]); + }); +}); diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/useOverrides.ts b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/useOverrides.ts new file mode 100644 index 0000000000..fa0a515d4c --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/useOverrides.ts @@ -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[1]; diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsCard.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsCard.tsx new file mode 100644 index 0000000000..7b676097bc --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsCard.tsx @@ -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 ( + + +
+ + + {environment.name} + +
+ {children} +
+ 0} + show={ + <> + + + 1} + show={ + <> + +

Stickiness

+ + By overriding the stickiness you can + control which parameter is used to + ensure consistent traffic allocation + across variants.{' '} + + Read more + + + + + } + /> + + } + /> +
+ ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/EnvironmentVariantsTable.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/EnvironmentVariantsTable.tsx new file mode 100644 index 0000000000..ffeafaec02 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/EnvironmentVariantsTable.tsx @@ -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 ( + + {calculateVariantWeight(weight)} % + + ); + }, + sortType: 'number', + }, + { + Header: 'Type', + accessor: 'weightType', + Cell: TextCell, + sortType: 'alphanumeric', + }, + { + Header: 'Actions', + id: 'Actions', + align: 'center', + Cell: ({ + row: { original }, + }: { + row: { original: IFeatureVariant }; + }) => ( + + ), + 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 ( + + + + + 0} + show={ + + No variants found matching “ + {searchValue} + ” + + } + /> + } + /> + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/OverridesCell/OverridesCell.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/OverridesCell/OverridesCell.tsx new file mode 100644 index 0000000000..cc565a1061 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/OverridesCell/OverridesCell.tsx @@ -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 ; + + const overrideToString = (override: IOverride) => + `${override.contextName}:${override.values.join()}`; + + return ( + + + {overrides.map((override, index) => ( + + + {overrideToString(override)} + + + ))} + + } + > + 0 && + overrides + ?.map(overrideToString) + .join('\n') + .toLowerCase() + .includes(searchQuery.toLowerCase()) + } + > + {overrides.length === 1 + ? '1 override' + : `${overrides.length} overrides`} + + + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/PayloadCell/PayloadCell.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/PayloadCell/PayloadCell.tsx new file mode 100644 index 0000000000..e9beea0b9d --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/PayloadCell/PayloadCell.tsx @@ -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 ; + + if (payload.type === 'string' && payload.value.length < 20) { + return ( + + {payload.value} + + ); + } + + return ( + + + + + {payload.value} + + + + } + > + 0 && + payload.value + .toLowerCase() + .includes(searchQuery.toLowerCase()) + } + > + {payload.type} + + + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/VariantsActionsCell/VariantsActionsCell.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/VariantsActionsCell/VariantsActionsCell.tsx new file mode 100644 index 0000000000..ce89938803 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/VariantsActionsCell/VariantsActionsCell.tsx @@ -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 ( + + editVariant(variant)} + tooltipProps={{ + title: 'Edit variant', + }} + > + + + deleteVariant(variant)} + tooltipProps={{ + title: 'Delete variant', + }} + > + + + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCopyFrom/EnvironmentVariantsCopyFrom.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCopyFrom/EnvironmentVariantsCopyFrom.tsx new file mode 100644 index 0000000000..82eca42ac9 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCopyFrom/EnvironmentVariantsCopyFrom.tsx @@ -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); + const copyFromOpen = Boolean(copyFromAnchorEl); + + return ( + 0} + show={ + <> + { + 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 + + setCopyFromAnchorEl(null)} + MenuListProps={{ + 'aria-labelledby': `copy-from-menu-${environment.name}`, + }} + > + {otherEnvsWithVariants.map(otherEnvironment => ( + + onCopyVariantsFrom( + otherEnvironment, + environment + ) + } + > + + {`Copy from ${otherEnvironment.name}`} + + + ))} + + + } + /> + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/FeatureEnvironmentVariants.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/FeatureEnvironmentVariants.tsx new file mode 100644 index 0000000000..0f3137d294 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/FeatureEnvironmentVariants.tsx @@ -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(); + const [selectedVariant, setSelectedVariant] = useState(); + 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 ( + + + + } + /> + } + > + + } + /> + + } + > + + 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 getVariant() method in + the Client SDK. + + {feature.environments.map(environment => { + const otherEnvsWithVariants = feature.environments.filter( + ({ name, variants }) => + name !== environment.name && variants?.length + ); + + return ( + addVariant(environment)} + onEditVariant={(variant: IFeatureVariant) => + editVariant(environment, variant) + } + onDeleteVariant={(variant: IFeatureVariant) => + deleteVariant(environment, variant) + } + onUpdateStickiness={(variants: IFeatureVariant[]) => + onUpdateStickiness(environment, variants) + } + > + + addVariant(environment)} + variant="outlined" + permission={UPDATE_FEATURE_VARIANTS} + projectId={projectId} + > + Add variant + + + + } + /> + + ); + })} + + + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/VariantDeleteDialog/VariantDeleteDialog.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/VariantDeleteDialog/VariantDeleteDialog.tsx new file mode 100644 index 0000000000..49ca3f87e4 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/VariantDeleteDialog/VariantDeleteDialog.tsx @@ -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>; + onConfirm: () => void; +} + +export const VariantDeleteDialog = ({ + variant, + open, + setOpen, + onConfirm, +}: IVariantDeleteDialogProps) => { + return ( + { + setOpen(false); + }} + > + + Deleting this variant will change which variant users receive. + + + You are about to delete variant:{' '} + {variant?.name} + + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariants.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariants.tsx index cc842ca49d..438916aed9 100644 --- a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariants.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariants.tsx @@ -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 ; + } + return ; }; diff --git a/frontend/src/hooks/api/actions/useFeatureApi/useFeatureApi.ts b/frontend/src/hooks/api/actions/useFeatureApi/useFeatureApi.ts index e0e39e73cc..0e1f14fe8f 100644 --- a/frontend/src/hooks/api/actions/useFeatureApi/useFeatureApi.ts +++ b/frontend/src/hooks/api/actions/useFeatureApi/useFeatureApi.ts @@ -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, }; diff --git a/frontend/src/hooks/api/getters/useFeature/useFeature.ts b/frontend/src/hooks/api/getters/useFeature/useFeature.ts index ae10fb4dd2..95e8c77ee5 100644 --- a/frontend/src/hooks/api/getters/useFeature/useFeature.ts +++ b/frontend/src/hooks/api/getters/useFeature/useFeature.ts @@ -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( ['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 => { - const res = await fetch(path); + const variantEnvironments = variantsPerEnvironment + ? '?variantEnvironments=true' + : ''; + + const res = await fetch(path + variantEnvironments); if (res.status === 404) { return { status: 404 }; diff --git a/frontend/src/interfaces/featureToggle.ts b/frontend/src/interfaces/featureToggle.ts index 15be3e0eed..79bc3ac490 100644 --- a/frontend/src/interfaces/featureToggle.ts +++ b/frontend/src/interfaces/featureToggle.ts @@ -37,6 +37,7 @@ export interface IFeatureEnvironment { name: string; enabled: boolean; strategies: IFeatureStrategy[]; + variants?: IFeatureVariant[]; } export interface IFeatureVariant { diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index b506616540..60d56877df 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -43,6 +43,7 @@ export interface IFlags { syncSSOGroups?: boolean; changeRequests?: boolean; cloneEnvironment?: boolean; + variantsPerEnvironment?: boolean; } export interface IVersionInfo { diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index d2c812bdc2..27d22f588a 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -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], diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 11d6f18072..ece845809d 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -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; } diff --git a/src/server-dev.ts b/src/server-dev.ts index fabbd332e3..c2b3b0393f 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -42,6 +42,7 @@ process.nextTick(async () => { changeRequests: true, cloneEnvironment: true, toggleTagFiltering: true, + variantsPerEnvironment: true, }, }, authentication: { diff --git a/src/test/config/test-config.ts b/src/test/config/test-config.ts index 79029869e3..646f67735a 100644 --- a/src/test/config/test-config.ts +++ b/src/test/config/test-config.ts @@ -30,6 +30,7 @@ export function createTestConfig(config?: IUnleashOptions): IUnleashConfig { syncSSOGroups: true, changeRequests: true, cloneEnvironment: true, + variantsPerEnvironment: true, }, }, };