From a29ef8dc0d2a1eb61331a7244b665e65b51c4e18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Mon, 30 Jan 2023 16:40:15 +0100 Subject: [PATCH] Revert "feat: new variants per env form (#3004)" This reverts commit cef35387c51dccfe963f88e0a26e4e2c281ae410. --- frontend/package.json | 1 - .../component/common/Input/Input.styles.ts | 4 - frontend/src/component/common/Input/Input.tsx | 1 - frontend/src/component/common/util.ts | 35 -- .../EnvironmentVariantModal.tsx | 570 ++++++++++++++++++ .../VariantOverrides/VariantOverrides.tsx | 0 .../VariantOverrides/useOverrides.test.ts | 0 .../VariantOverrides/useOverrides.ts | 0 .../EnvironmentVariantsCard.tsx | 63 +- .../EnvironmentVariantsTable.tsx | 47 +- .../VariantsActionsCell.tsx | 57 ++ .../EnvironmentVariantsModal.tsx | 424 ------------- .../VariantForm/VariantForm.tsx | 417 ------------- .../FeatureEnvironmentVariants.tsx | 162 +++-- .../VariantDeleteDialog.tsx | 42 ++ frontend/yarn.lock | 5 - 16 files changed, 864 insertions(+), 964 deletions(-) create mode 100644 frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/EnvironmentVariantModal.tsx rename frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/{EnvironmentVariantsModal/VariantForm => EnvironmentVariantModal}/VariantOverrides/VariantOverrides.tsx (100%) rename frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/{EnvironmentVariantsModal/VariantForm => EnvironmentVariantModal}/VariantOverrides/useOverrides.test.ts (100%) rename frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/{EnvironmentVariantsModal/VariantForm => EnvironmentVariantModal}/VariantOverrides/useOverrides.ts (100%) create mode 100644 frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/VariantsActionsCell/VariantsActionsCell.tsx delete mode 100644 frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/EnvironmentVariantsModal.tsx delete mode 100644 frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm/VariantForm.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/VariantDeleteDialog/VariantDeleteDialog.tsx diff --git a/frontend/package.json b/frontend/package.json index 635224f9df..bff0637c32 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -57,7 +57,6 @@ "@types/react-test-renderer": "17.0.2", "@types/react-timeago": "4.1.3", "@types/semver": "7.3.13", - "@types/uuid": "^9.0.0", "@uiw/codemirror-theme-duotone": "4.19.6", "@uiw/react-codemirror": "4.19.6", "@vitejs/plugin-react": "3.0.1", diff --git a/frontend/src/component/common/Input/Input.styles.ts b/frontend/src/component/common/Input/Input.styles.ts index 2a2944a01b..a5cd6824e1 100644 --- a/frontend/src/component/common/Input/Input.styles.ts +++ b/frontend/src/component/common/Input/Input.styles.ts @@ -4,9 +4,5 @@ export const useStyles = makeStyles()(theme => ({ helperText: { position: 'absolute', bottom: '-1rem', - whiteSpace: 'nowrap', - maxWidth: '100%', - overflow: 'hidden', - textOverflow: 'ellipsis', }, })); diff --git a/frontend/src/component/common/Input/Input.tsx b/frontend/src/component/common/Input/Input.tsx index 82ed384f4d..d05ce5b1d6 100644 --- a/frontend/src/component/common/Input/Input.tsx +++ b/frontend/src/component/common/Input/Input.tsx @@ -48,7 +48,6 @@ const Input = ({ onChange={onChange} FormHelperTextProps={{ ['data-testid']: INPUT_ERROR_TEXT, - title: errorText, classes: { root: styles.helperText, }, diff --git a/frontend/src/component/common/util.ts b/frontend/src/component/common/util.ts index ad28583e0f..cafde7c8c8 100644 --- a/frontend/src/component/common/util.ts +++ b/frontend/src/component/common/util.ts @@ -3,7 +3,6 @@ import { IUiConfig } from 'interfaces/uiConfig'; import { INavigationMenuItem } from 'interfaces/route'; import { IFeatureVariant } from 'interfaces/featureToggle'; import { format, isValid } from 'date-fns'; -import { IFeatureVariantEdit } from 'component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/EnvironmentVariantsModal'; export const filterByConfig = (config: IUiConfig) => (r: INavigationMenuItem) => { @@ -91,40 +90,6 @@ export function updateWeight(variants: IFeatureVariant[], totalWeight: number) { }); } -export function updateWeightEdit( - variants: IFeatureVariantEdit[], - totalWeight: number -) { - if (variants.length === 0) { - return []; - } - const { remainingPercentage, variableVariantCount } = variants.reduce( - ({ remainingPercentage, variableVariantCount }, variant) => { - if (variant.weight && variant.weightType === weightTypes.FIX) { - remainingPercentage -= Number(variant.weight); - } else { - variableVariantCount += 1; - } - return { - remainingPercentage, - variableVariantCount, - }; - }, - { remainingPercentage: totalWeight, variableVariantCount: 0 } - ); - - const percentage = parseInt( - String(remainingPercentage / variableVariantCount) - ); - - return variants.map(variant => { - if (variant.weightType !== weightTypes.FIX) { - variant.weight = percentage; - } - return variant; - }); -} - export const modalStyles = { overlay: { position: 'absolute', 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..f96b8fe8f7 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/EnvironmentVariantModal.tsx @@ -0,0 +1,570 @@ +import { + Alert, + Button, + FormControlLabel, + InputAdornment, + styled, +} from '@mui/material'; +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { FormEvent, useEffect, useState } from 'react'; +import Input from 'component/common/Input/Input'; +import { HelpIcon } from 'component/common/HelpIcon/HelpIcon'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { + IFeatureEnvironment, + IFeatureVariant, + IPayload, +} from 'interfaces/featureToggle'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { Operation } from 'fast-json-patch'; +import { useOverrides } from 'component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/useOverrides'; +import SelectMenu from 'component/common/select'; +import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext'; +import { OverrideConfig } from 'component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/VariantOverrides'; +import cloneDeep from 'lodash.clonedeep'; +import { CloudCircle } from '@mui/icons-material'; +import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch'; +import { UPDATE_FEATURE_VARIANTS } from 'component/providers/AccessProvider/permissions'; +import { WeightType } from 'constants/variantTypes'; +import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests'; +import { useChangeRequestInReviewWarning } from 'hooks/useChangeRequestInReviewWarning'; +import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; + +const StyledFormSubtitle = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + marginTop: theme.spacing(-1.5), + marginBottom: theme.spacing(4), +})); + +const StyledCloudCircle = styled(CloudCircle, { + shouldForwardProp: prop => prop !== 'deprecated', +})<{ deprecated?: boolean }>(({ theme, deprecated }) => ({ + color: deprecated + ? theme.palette.neutral.border + : theme.palette.primary.main, +})); + +const StyledName = styled('span', { + shouldForwardProp: prop => prop !== 'deprecated', +})<{ deprecated?: boolean }>(({ theme, deprecated }) => ({ + color: deprecated + ? theme.palette.text.secondary + : theme.palette.text.primary, + marginLeft: theme.spacing(1.25), +})); + +const StyledForm = styled('form')(() => ({ + display: 'flex', + flexDirection: 'column', + height: '100%', +})); + +const StyledInputDescription = styled('p')(({ theme }) => ({ + display: 'flex', + color: theme.palette.text.primary, + marginBottom: theme.spacing(1), + '&:not(:first-of-type)': { + marginTop: theme.spacing(4), + }, +})); + +const StyledFormControlLabel = styled(FormControlLabel)(({ theme }) => ({ + marginTop: theme.spacing(4), + marginBottom: theme.spacing(1.5), +})); + +const StyledInputSecondaryDescription = styled('p')(({ theme }) => ({ + color: theme.palette.text.secondary, + marginBottom: theme.spacing(1), +})); + +const StyledInput = styled(Input)(() => ({ + width: '100%', +})); + +const StyledRow = styled('div')(({ theme }) => ({ + display: 'flex', + rowGap: theme.spacing(1.5), + marginBottom: theme.spacing(2), + [theme.breakpoints.down('sm')]: { + flexDirection: 'column', + '& > div, .MuiInputBase-root': { + width: '100%', + }, + }, +})); + +const StyledSelectMenu = styled(SelectMenu)(({ theme }) => ({ + minWidth: theme.spacing(20), + marginRight: theme.spacing(10), +})); + +const StyledCRAlert = styled(Alert)(({ theme }) => ({ + marginBottom: theme.spacing(2), +})); + +const StyledAlert = styled(Alert)(({ theme }) => ({ + marginTop: theme.spacing(4), +})); + +const StyledButtonContainer = styled('div')(({ theme }) => ({ + marginTop: 'auto', + display: 'flex', + justifyContent: 'flex-end', + [theme.breakpoints.down('sm')]: { + marginTop: theme.spacing(4), + }, +})); + +const StyledCancelButton = styled(Button)(({ theme }) => ({ + marginLeft: theme.spacing(3), +})); + +const payloadOptions = [ + { key: 'string', label: 'string' }, + { key: 'json', label: 'json' }, + { key: 'csv', label: 'csv' }, +]; + +const EMPTY_PAYLOAD = { type: 'string', value: '' }; + +enum ErrorField { + NAME = 'name', + PERCENTAGE = 'percentage', + PAYLOAD = 'payload', + OTHER = 'other', +} + +interface IEnvironmentVariantModalErrors { + [ErrorField.NAME]?: string; + [ErrorField.PERCENTAGE]?: string; + [ErrorField.PAYLOAD]?: string; + [ErrorField.OTHER]?: string; +} + +interface IEnvironmentVariantModalProps { + environment?: IFeatureEnvironment; + variant?: IFeatureVariant; + open: boolean; + setOpen: React.Dispatch>; + getApiPayload: ( + variants: IFeatureVariant[], + newVariants: IFeatureVariant[] + ) => { patch: Operation[]; error?: string }; + getCrPayload: (variants: IFeatureVariant[]) => { + feature: string; + action: 'patchVariant'; + payload: { variants: IFeatureVariant[] }; + }; + onConfirm: (updatedVariants: IFeatureVariant[]) => void; +} + +export const EnvironmentVariantModal = ({ + environment, + variant, + open, + setOpen, + getApiPayload, + getCrPayload, + onConfirm, +}: IEnvironmentVariantModalProps) => { + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + + const { uiConfig } = useUiConfig(); + + const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); + const { data } = usePendingChangeRequests(projectId); + const { changeRequestInReviewOrApproved, alert } = + useChangeRequestInReviewWarning(data); + + const [name, setName] = useState(''); + const [customPercentage, setCustomPercentage] = useState(false); + const [percentage, setPercentage] = useState(''); + const [payload, setPayload] = useState(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()); + const crPayload = getCrPayload(getUpdatedVariants()); + + useEffect(() => { + clearError(ErrorField.PERCENTAGE); + clearError(ErrorField.OTHER); + if (apiPayload.error) { + if (apiPayload.error.includes('%')) { + setError(ErrorField.PERCENTAGE, apiPayload.error); + } else { + setError(ErrorField.OTHER, apiPayload.error); + } + } + }, [apiPayload.error]); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + onConfirm(getUpdatedVariants()); + }; + + const formatApiCode = () => + isChangeRequest + ? `curl --location --request POST '${ + uiConfig.unleashUrl + }/api/admin/projects/${projectId}/environments/${ + environment?.name + }/change-requests' \\ + --header 'Authorization: INSERT_API_KEY' \\ + --header 'Content-Type: application/json' \\ + --data-raw '${JSON.stringify(crPayload, undefined, 2)}'` + : `curl --location --request PATCH '${ + uiConfig.unleashUrl + }/api/admin/projects/${projectId}/features/${featureId}/environments/${ + environment?.name + }/variants' \\ + --header 'Authorization: INSERT_API_KEY' \\ + --header 'Content-Type: application/json' \\ + --data-raw '${JSON.stringify(apiPayload.patch, undefined, 2)}'`; + + const isNameNotEmpty = (name: string) => name.length; + const isNameUnique = (name: string) => + editing || !variants.some(variant => variant.name === name); + const isValidPercentage = (percentage: string) => { + if (!customPercentage) return true; + if (percentage === '') return false; + if (percentage.match(/\.[0-9]{2,}$/)) return false; + + const percentageNumber = Number(percentage); + return percentageNumber >= 0 && percentageNumber <= 100; + }; + const isValidPayload = (payload: IPayload): boolean => { + try { + if (payload.type === 'json') { + JSON.parse(payload.value); + } + return true; + } catch (e: unknown) { + return false; + } + }; + const isValid = + isNameNotEmpty(name) && + isNameUnique(name) && + isValidPercentage(percentage) && + isValidPayload(payload) && + !apiPayload.error; + + const onSetName = (name: string) => { + clearError(ErrorField.NAME); + if (!isNameUnique(name)) { + setError( + ErrorField.NAME, + 'A variant with that name already exists for this environment.' + ); + } + setName(name); + }; + + const onSetPercentage = (percentage: string) => { + if (percentage === '' || isValidPercentage(percentage)) { + setPercentage(percentage); + } + }; + + const validatePayload = (payload: IPayload) => { + if (!isValidPayload(payload)) { + setError(ErrorField.PAYLOAD, 'Invalid JSON.'); + } + }; + + const onAddOverride = () => { + if (context.length > 0) { + overridesDispatch({ + type: 'ADD', + payload: { contextName: context[0].name, values: [] }, + }); + } + }; + + const hasChangeRequestInReviewForEnvironment = + changeRequestInReviewOrApproved(environment?.name || ''); + + const changeRequestButtonText = hasChangeRequestInReviewForEnvironment + ? 'Add to existing change request' + : 'Add change to draft'; + + const isChangeRequest = + isChangeRequestConfigured(environment?.name || '') && + uiConfig.flags.crOnVariants; + + return ( + { + setOpen(false); + }} + label={editing ? 'Edit variant' : 'Add variant'} + > + + + + + {environment?.name} + + + +
+ + Change requests are + enabled + {environment + ? ` for ${environment.name}` + : ''} + . Your changes need to be approved + before they will be live. All the + changes you do now will be added + into a draft that you can submit for + review. + + } + /> + } + /> + + 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/EnvironmentVariantsModal/VariantForm/VariantOverrides/VariantOverrides.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/VariantOverrides.tsx similarity index 100% rename from frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm/VariantOverrides/VariantOverrides.tsx rename to frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/VariantOverrides.tsx diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm/VariantOverrides/useOverrides.test.ts b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/useOverrides.test.ts similarity index 100% rename from frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm/VariantOverrides/useOverrides.test.ts rename to frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/useOverrides.test.ts diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm/VariantOverrides/useOverrides.ts b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/useOverrides.ts similarity index 100% rename from frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm/VariantOverrides/useOverrides.ts rename to frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/useOverrides.ts diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsCard.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsCard.tsx index 96bbd44455..fa8ccf7a9c 100644 --- a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsCard.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsCard.tsx @@ -1,9 +1,11 @@ import { CloudCircle } from '@mui/icons-material'; import { styled } from '@mui/material'; -import { IFeatureEnvironment } from 'interfaces/featureToggle'; +import { IFeatureEnvironment, IFeatureVariant } from 'interfaces/featureToggle'; import { EnvironmentVariantsTable } from './EnvironmentVariantsTable/EnvironmentVariantsTable'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; -import { Badge } from 'component/common/Badge/Badge'; +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), @@ -14,7 +16,7 @@ const StyledCard = styled('div')(({ theme }) => ({ }, })); -const StyledHeader = styled('div')({ +const StyledHeader = styled('div')(() => ({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', @@ -22,7 +24,7 @@ const StyledHeader = styled('div')({ display: 'flex', alignItems: 'center', }, -}); +})); const StyledCloudCircle = styled(CloudCircle, { shouldForwardProp: prop => prop !== 'deprecated', @@ -39,7 +41,6 @@ const StyledName = styled('span', { ? theme.palette.text.secondary : theme.palette.text.primary, marginLeft: theme.spacing(1.25), - fontWeight: theme.fontWeight.bold, })); const StyledDescription = styled('p')(({ theme }) => ({ @@ -48,27 +49,57 @@ const StyledDescription = styled('p')(({ theme }) => ({ marginBottom: theme.spacing(1.5), })); -const StyledStickinessContainer = styled('div')(({ theme }) => ({ - display: 'flex', - alignItems: 'center', - gap: theme.spacing(1.5), - marginBottom: theme.spacing(0.5), +const StyledGeneralSelect = styled(GeneralSelect)(({ theme }) => ({ + minWidth: theme.spacing(20), })); interface IEnvironmentVariantsCardProps { environment: IFeatureEnvironment; searchValue: string; + onEditVariant: (variant: IFeatureVariant) => void; + onDeleteVariant: (variant: IFeatureVariant) => void; + onUpdateStickiness: (variant: IFeatureVariant[]) => void; children?: React.ReactNode; } export const EnvironmentVariantsCard = ({ environment, searchValue, + onEditVariant, + onDeleteVariant, + onUpdateStickiness, children, }: IEnvironmentVariantsCardProps) => { + const { context } = useUnleashContext(); + const variants = environment.variants ?? []; const stickiness = variants[0]?.stickiness || 'default'; + const stickinessOptions = useMemo( + () => [ + 'default', + ...context.filter(c => c.stickiness).map(c => c.name), + ], + [context] + ); + + const options = stickinessOptions.map(c => ({ key: c, label: c })); + if (!stickinessOptions.includes(stickiness)) { + options.push({ key: stickiness, label: stickiness }); + } + + const updateStickiness = async (stickiness: string) => { + const newVariants = [...variants].map(variant => ({ + ...variant, + stickiness, + })); + onUpdateStickiness(newVariants); + }; + + const onStickinessChange = (value: string) => { + updateStickiness(value).catch(console.warn); + }; + return ( @@ -87,15 +118,14 @@ export const EnvironmentVariantsCard = ({ 1} show={ <> - -

Stickiness:

- {stickiness} -
+

Stickiness

By overriding the stickiness you can control which parameter is used to @@ -109,6 +139,11 @@ export const EnvironmentVariantsCard = ({ 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 index f288f70977..c431d5cc33 100644 --- a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/EnvironmentVariantsTable.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/EnvironmentVariantsTable.tsx @@ -20,6 +20,7 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useSearch } from 'hooks/useSearch'; import { IFeatureEnvironment, + IFeatureVariant, IOverride, IPayload, } from 'interfaces/featureToggle'; @@ -28,7 +29,9 @@ import { useSortBy, useTable } from 'react-table'; import { sortTypes } from 'utils/sortTypes'; import { PayloadCell } from './PayloadCell/PayloadCell'; import { OverridesCell } from './OverridesCell/OverridesCell'; +import { VariantsActionCell } from './VariantsActionsCell/VariantsActionsCell'; import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns'; +import { WeightType } from 'constants/variantTypes'; const StyledTableContainer = styled('div')(({ theme }) => ({ margin: theme.spacing(3, 0), @@ -37,11 +40,15 @@ const StyledTableContainer = styled('div')(({ theme }) => ({ interface IEnvironmentVariantsTableProps { environment: IFeatureEnvironment; searchValue: string; + onEditVariant: (variant: IFeatureVariant) => void; + onDeleteVariant: (variant: IFeatureVariant) => void; } export const EnvironmentVariantsTable = ({ environment, searchValue, + onEditVariant, + onDeleteVariant, }: IEnvironmentVariantsTableProps) => { const projectId = useRequiredPathParam('projectId'); @@ -101,11 +108,30 @@ export const EnvironmentVariantsTable = ({ }, { Header: 'Type', - accessor: (row: any) => - row.weightType === 'fix' ? 'Fixed' : 'Variable', + accessor: 'weightType', Cell: TextCell, sortType: 'alphanumeric', }, + { + Header: 'Actions', + id: 'Actions', + align: 'center', + Cell: ({ + row: { original }, + }: { + row: { original: IFeatureVariant }; + }) => ( + + ), + disableSortBy: true, + }, ], [projectId, variants, environment] ); @@ -117,6 +143,23 @@ export const EnvironmentVariantsTable = ({ [] ); + const isProtectedVariant = (variant: IFeatureVariant): boolean => { + const isVariable = variant.weightType === WeightType.VARIABLE; + + const atLeastOneFixedVariant = variants.some(variant => { + return variant.weightType === WeightType.FIX; + }); + + const hasOnlyOneVariableVariant = + variants.filter(variant => { + return variant.weightType === WeightType.VARIABLE; + }).length == 1; + + return ( + atLeastOneFixedVariant && hasOnlyOneVariableVariant && isVariable + ); + }; + const { data, getSearchText } = useSearch(columns, searchValue, variants); const { 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..e242030370 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/VariantsActionsCell/VariantsActionsCell.tsx @@ -0,0 +1,57 @@ +import { Edit, Delete } from '@mui/icons-material'; +import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; +import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell'; +import { UPDATE_FEATURE_ENVIRONMENT_VARIANTS } from 'component/providers/AccessProvider/permissions'; +import { IFeatureVariant } from 'interfaces/featureToggle'; + +interface IVariantsActionCellProps { + projectId: string; + environmentId: string; + variant: IFeatureVariant; + isLastVariableVariant: boolean; + editVariant: (variant: IFeatureVariant) => void; + deleteVariant: (variant: IFeatureVariant) => void; +} + +export const VariantsActionCell = ({ + projectId, + environmentId, + variant, + isLastVariableVariant, + editVariant, + deleteVariant, +}: IVariantsActionCellProps) => { + return ( + + editVariant(variant)} + tooltipProps={{ + title: 'Edit variant', + }} + > + + + deleteVariant(variant)} + tooltipProps={{ + title: isLastVariableVariant + ? 'You need to have at least one variable variant' + : 'Delete variant', + }} + > + + + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/EnvironmentVariantsModal.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/EnvironmentVariantsModal.tsx deleted file mode 100644 index 07a6ed9409..0000000000 --- a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/EnvironmentVariantsModal.tsx +++ /dev/null @@ -1,424 +0,0 @@ -import { Alert, Button, styled } from '@mui/material'; -import FormTemplate from 'component/common/FormTemplate/FormTemplate'; -import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; -import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; -import { FormEvent, useEffect, useMemo, useState } from 'react'; -import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; -import { IFeatureEnvironment, IFeatureVariant } from 'interfaces/featureToggle'; -import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; -import { Operation } from 'fast-json-patch'; -import { CloudCircle } from '@mui/icons-material'; -import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests'; -import { useChangeRequestInReviewWarning } from 'hooks/useChangeRequestInReviewWarning'; -import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; -import { VariantForm } from './VariantForm/VariantForm'; -import PermissionButton from 'component/common/PermissionButton/PermissionButton'; -import { UPDATE_FEATURE_ENVIRONMENT_VARIANTS } from 'component/providers/AccessProvider/permissions'; -import { WeightType } from 'constants/variantTypes'; -import { v4 as uuidv4 } from 'uuid'; -import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext'; -import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect'; -import { updateWeightEdit } from 'component/common/util'; - -const StyledFormSubtitle = styled('div')(({ theme }) => ({ - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - '& > div': { - display: 'flex', - alignItems: 'center', - }, - marginTop: theme.spacing(-1.5), - marginBottom: theme.spacing(4), -})); - -const StyledCloudCircle = styled(CloudCircle, { - shouldForwardProp: prop => prop !== 'deprecated', -})<{ deprecated?: boolean }>(({ theme, deprecated }) => ({ - color: deprecated - ? theme.palette.neutral.border - : theme.palette.primary.main, -})); - -const StyledName = styled('span', { - shouldForwardProp: prop => prop !== 'deprecated', -})<{ deprecated?: boolean }>(({ theme, deprecated }) => ({ - color: deprecated - ? theme.palette.text.secondary - : theme.palette.text.primary, - marginLeft: theme.spacing(1.25), - fontSize: theme.fontSizes.mainHeader, - fontWeight: theme.fontWeight.bold, -})); - -const StyledForm = styled('form')(() => ({ - display: 'flex', - flexDirection: 'column', - height: '100%', -})); - -const StyledCRAlert = styled(Alert)(({ theme }) => ({ - marginBottom: theme.spacing(2), -})); - -const StyledAlert = styled(Alert)(({ theme }) => ({ - marginTop: theme.spacing(4), -})); - -const StyledVariantForms = styled('div')(({ theme }) => ({ - display: 'flex', - flexDirection: 'column-reverse', -})); - -const StyledStickinessContainer = styled('div')(({ theme }) => ({ - display: 'flex', - alignItems: 'center', - gap: theme.spacing(1.5), - marginBottom: theme.spacing(0.5), -})); - -const StyledDescription = styled('p')(({ theme }) => ({ - fontSize: theme.fontSizes.smallBody, - color: theme.palette.text.secondary, - marginBottom: theme.spacing(1.5), -})); - -const StyledGeneralSelect = styled(GeneralSelect)(({ theme }) => ({ - minWidth: theme.spacing(20), - width: '100%', -})); - -const StyledButtonContainer = styled('div')(({ theme }) => ({ - marginTop: 'auto', - paddingTop: theme.spacing(4), - display: 'flex', - justifyContent: 'flex-end', -})); - -const StyledCancelButton = styled(Button)(({ theme }) => ({ - marginLeft: theme.spacing(3), -})); - -export type IFeatureVariantEdit = IFeatureVariant & { - isValid: boolean; - new: boolean; - id: string; -}; - -interface IEnvironmentVariantModalProps { - environment?: IFeatureEnvironment; - open: boolean; - setOpen: React.Dispatch>; - getApiPayload: ( - variants: IFeatureVariant[], - newVariants: IFeatureVariant[] - ) => { patch: Operation[]; error?: string }; - getCrPayload: (variants: IFeatureVariant[]) => { - feature: string; - action: 'patchVariant'; - payload: { variants: IFeatureVariant[] }; - }; - onConfirm: (updatedVariants: IFeatureVariant[]) => void; -} - -export const EnvironmentVariantsModal = ({ - environment, - open, - setOpen, - getApiPayload, - getCrPayload, - onConfirm, -}: IEnvironmentVariantModalProps) => { - const projectId = useRequiredPathParam('projectId'); - const featureId = useRequiredPathParam('featureId'); - - const { uiConfig } = useUiConfig(); - const { context } = useUnleashContext(); - - const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); - const { data } = usePendingChangeRequests(projectId); - const { changeRequestInReviewOrApproved, alert } = - useChangeRequestInReviewWarning(data); - - const oldVariants = environment?.variants || []; - const [variantsEdit, setVariantsEdit] = useState([]); - - useEffect(() => { - setVariantsEdit( - oldVariants.length - ? oldVariants.map(oldVariant => ({ - ...oldVariant, - isValid: true, - new: false, - id: uuidv4(), - })) - : [ - { - name: '', - weightType: WeightType.VARIABLE, - weight: 0, - overrides: [], - stickiness: - variantsEdit?.length > 0 - ? variantsEdit[0].stickiness - : 'default', - new: true, - isValid: false, - id: uuidv4(), - }, - ] - ); - }, [open]); - - const updateVariant = (updatedVariant: IFeatureVariantEdit, id: string) => { - setVariantsEdit(prevVariants => - updateWeightEdit( - prevVariants.map(prevVariant => - prevVariant.id === id ? updatedVariant : prevVariant - ), - 1000 - ) - ); - }; - - const variants = variantsEdit.map( - ({ new: _, isValid: __, id: ___, ...rest }) => rest - ); - - const apiPayload = getApiPayload(oldVariants, variants); - const crPayload = getCrPayload(variants); - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - onConfirm(variants); - }; - - const formatApiCode = () => - isChangeRequest - ? `curl --location --request POST '${ - uiConfig.unleashUrl - }/api/admin/projects/${projectId}/environments/${ - environment?.name - }/change-requests' \\ - --header 'Authorization: INSERT_API_KEY' \\ - --header 'Content-Type: application/json' \\ - --data-raw '${JSON.stringify(crPayload, undefined, 2)}'` - : `curl --location --request PATCH '${ - uiConfig.unleashUrl - }/api/admin/projects/${projectId}/features/${featureId}/environments/${ - environment?.name - }/variants' \\ - --header 'Authorization: INSERT_API_KEY' \\ - --header 'Content-Type: application/json' \\ - --data-raw '${JSON.stringify(apiPayload.patch, undefined, 2)}'`; - - const isValid = variantsEdit.every(({ isValid }) => isValid); - - const hasChangeRequestInReviewForEnvironment = - changeRequestInReviewOrApproved(environment?.name || ''); - - const changeRequestButtonText = hasChangeRequestInReviewForEnvironment - ? 'Add to existing change request' - : 'Add change to draft'; - - const isChangeRequest = - isChangeRequestConfigured(environment?.name || '') && - uiConfig.flags.crOnVariants; - - const stickiness = variants[0]?.stickiness || 'default'; - const stickinessOptions = useMemo( - () => [ - 'default', - ...context.filter(c => c.stickiness).map(c => c.name), - ], - [context] - ); - const options = stickinessOptions.map(c => ({ key: c, label: c })); - if (!stickinessOptions.includes(stickiness)) { - options.push({ key: stickiness, label: stickiness }); - } - - const updateStickiness = async (stickiness: string) => { - setVariantsEdit(prevVariants => - prevVariants.map(prevVariant => ({ - ...prevVariant, - stickiness, - })) - ); - }; - - const onStickinessChange = (value: string) => { - updateStickiness(value).catch(console.warn); - }; - - const [error, setError] = useState(); - useEffect(() => { - setError(undefined); - if (apiPayload.error) { - setError(apiPayload.error); - } - }, [apiPayload.error]); - - return ( - { - setOpen(false); - }} - label="" - > - - -
- - - {environment?.name} - -
- - setVariantsEdit(variantsEdit => [ - ...variantsEdit, - { - name: '', - weightType: WeightType.VARIABLE, - weight: 0, - overrides: [], - stickiness: - variantsEdit?.length > 0 - ? variantsEdit[0].stickiness - : 'default', - new: true, - isValid: false, - id: uuidv4(), - }, - ]) - } - variant="outlined" - permission={UPDATE_FEATURE_ENVIRONMENT_VARIANTS} - projectId={projectId} - environmentId={environment?.name} - > - Add variant - -
- - - Change requests are - enabled - {environment - ? ` for ${environment.name}` - : ''} - . Your changes need to be approved - before they will be live. All the - changes you do now will be added into a - draft that you can submit for review. - - } - /> - } - /> - - {variantsEdit.map(variant => ( - - updateVariant(updatedVariant, variant.id) - } - removeVariant={() => - setVariantsEdit(variantsEdit => - updateWeightEdit( - variantsEdit.filter( - v => v.id !== variant.id - ), - 1000 - ) - ) - } - projectId={projectId} - apiPayload={apiPayload} - /> - ))} - - 0} - show={ - <> - -

Stickiness

-
- - By overriding the stickiness you can control - which parameter is used to ensure consistent - traffic allocation across variants.{' '} - - Read more - - -
- -
- - } - elseShow={ - - This environment has no variants. Get started by - adding a variant. - - } - /> - - - - - - { - setOpen(false); - }} - > - Cancel - - -
-
-
- ); -}; diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm/VariantForm.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm/VariantForm.tsx deleted file mode 100644 index 6caf70b213..0000000000 --- a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm/VariantForm.tsx +++ /dev/null @@ -1,417 +0,0 @@ -import { useEffect, useState } from 'react'; -import Input from 'component/common/Input/Input'; -import { HelpIcon } from 'component/common/HelpIcon/HelpIcon'; -import SelectMenu from 'component/common/select'; -import { OverrideConfig } from 'component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm/VariantOverrides/VariantOverrides'; -import { - Button, - FormControlLabel, - IconButton, - InputAdornment, - styled, - Switch, -} from '@mui/material'; -import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; -import { IPayload } from 'interfaces/featureToggle'; -import { useOverrides } from 'component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm/VariantOverrides/useOverrides'; -import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext'; -import { WeightType } from 'constants/variantTypes'; -import { IFeatureVariantEdit } from '../EnvironmentVariantsModal'; -import { Operation } from 'fast-json-patch'; -import { Delete } from '@mui/icons-material'; - -const StyledVariantForm = styled('div')(({ theme }) => ({ - position: 'relative', - backgroundColor: theme.palette.neutral.light, - display: 'flex', - flexDirection: 'column', - padding: theme.spacing(3), - marginBottom: theme.spacing(3), - borderRadius: theme.shape.borderRadiusLarge, -})); - -const StyledDeleteButton = styled(IconButton)(({ theme }) => ({ - position: 'absolute', - top: theme.spacing(2), - right: theme.spacing(2), -})); - -const StyledLabel = styled('p')(({ theme }) => ({ - fontSize: theme.fontSizes.smallBody, -})); - -const StyledMarginLabel = styled(StyledLabel)(({ theme }) => ({ - display: 'flex', - color: theme.palette.text.primary, - marginTop: theme.spacing(1), - marginBottom: theme.spacing(2), -})); - -const StyledSubLabel = styled('p')(({ theme }) => ({ - fontSize: theme.fontSizes.smallBody, - color: theme.palette.text.secondary, - marginBottom: theme.spacing(2), -})); - -const StyledFormControlLabel = styled(FormControlLabel)(({ theme }) => ({ - marginBottom: theme.spacing(1), - '& > span': { - fontSize: theme.fontSizes.smallBody, - }, - [theme.breakpoints.down('sm')]: { - marginTop: theme.spacing(1), - marginBottom: theme.spacing(1.5), - }, -})); - -const StyledInput = styled(Input)(() => ({ - width: '100%', -})); - -const StyledPercentageContainer = styled('div')(({ theme }) => ({ - marginLeft: theme.spacing(3), -})); - -const StyledWeightInput = styled(Input)(({ theme }) => ({ - width: theme.spacing(24), - [theme.breakpoints.down('sm')]: { - width: '100%', - }, -})); - -const StyledNameContainer = styled('div')(({ theme }) => ({ - marginTop: theme.spacing(3), - flexGrow: 1, -})); - -const StyledRow = styled('div')(({ theme }) => ({ - display: 'flex', - rowGap: theme.spacing(1.5), - marginBottom: theme.spacing(2), - [theme.breakpoints.down('sm')]: { - flexDirection: 'column', - '& > div, .MuiInputBase-root': { - width: '100%', - }, - }, -})); - -const StyledTopRow = styled(StyledRow)({ - alignItems: 'end', - justifyContent: 'space-between', -}); - -const StyledSelectMenu = styled(SelectMenu)(({ theme }) => ({ - minWidth: theme.spacing(20), - marginRight: theme.spacing(10), -})); - -const StyledAddOverrideButton = styled(Button)(({ theme }) => ({ - width: theme.spacing(20), - maxWidth: '100%', -})); - -const payloadOptions = [ - { key: 'string', label: 'string' }, - { key: 'json', label: 'json' }, - { key: 'csv', label: 'csv' }, -]; - -const EMPTY_PAYLOAD = { type: 'string', value: '' }; - -enum ErrorField { - NAME = 'name', - PERCENTAGE = 'percentage', - PAYLOAD = 'payload', - OTHER = 'other', -} - -interface IVariantFormErrors { - [ErrorField.NAME]?: string; - [ErrorField.PERCENTAGE]?: string; - [ErrorField.PAYLOAD]?: string; - [ErrorField.OTHER]?: string; -} - -interface IVariantFormProps { - variant: IFeatureVariantEdit; - variants: IFeatureVariantEdit[]; - updateVariant: (updatedVariant: IFeatureVariantEdit) => void; - removeVariant: (variantId: string) => void; - projectId: string; - apiPayload: { - patch: Operation[]; - error?: string; - }; -} - -export const VariantForm = ({ - variant, - variants, - updateVariant, - removeVariant, - apiPayload, -}: IVariantFormProps) => { - const [name, setName] = useState(variant.name); - const [customPercentage, setCustomPercentage] = useState( - variant.weightType === WeightType.FIX - ); - const [percentage, setPercentage] = useState(String(variant.weight / 10)); - const [payload, setPayload] = useState( - variant.payload || EMPTY_PAYLOAD - ); - const [overrides, overridesDispatch] = useOverrides( - variant.overrides || [] - ); - 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 })); - }; - - useEffect(() => { - clearError(ErrorField.PERCENTAGE); - if (apiPayload.error?.includes('%')) { - setError(ErrorField.PERCENTAGE, 'Total weight must equal 100%'); - } - }, [apiPayload.error]); - - const editing = !variant.new; - const customPercentageVisible = - variants.filter( - ({ id, weightType }) => - id !== variant.id && weightType === WeightType.VARIABLE - ).length > 0; - - const isProtectedVariant = (variant: IFeatureVariantEdit): boolean => { - const isVariable = variant.weightType === WeightType.VARIABLE; - - const atLeastOneFixedVariant = variants.some(variant => { - return variant.weightType === WeightType.FIX; - }); - - const hasOnlyOneVariableVariant = - variants.filter(variant => { - return variant.weightType === WeightType.VARIABLE; - }).length == 1; - - return ( - atLeastOneFixedVariant && hasOnlyOneVariableVariant && isVariable - ); - }; - - const onSetName = (name: string) => { - clearError(ErrorField.NAME); - if (!isNameUnique(name, variant.id)) { - setError( - ErrorField.NAME, - 'A variant with that name already exists for this environment.' - ); - } - setName(name); - }; - - const onSetPercentage = (percentage: string) => { - if (percentage === '' || isValidPercentage(percentage)) { - setPercentage(percentage); - } - }; - - const validatePayload = (payload: IPayload) => { - if (!isValidPayload(payload)) { - setError(ErrorField.PAYLOAD, 'Invalid JSON.'); - } - }; - - const onAddOverride = () => { - if (context.length > 0) { - overridesDispatch({ - type: 'ADD', - payload: { contextName: context[0].name, values: [] }, - }); - } - }; - - const isNameNotEmpty = (name: string) => Boolean(name.length); - const isNameUnique = (name: string, id: string) => - editing || - !variants.some(variant => variant.name === name && variant.id !== id); - const isValidPercentage = (percentage: string) => { - if (!customPercentage) return true; - if (percentage === '') return false; - if (percentage.match(/\.[0-9]{2,}$/)) return false; - - const percentageNumber = Number(percentage); - return percentageNumber >= 0 && percentageNumber <= 100; - }; - const isValidPayload = (payload: IPayload): boolean => { - try { - if (payload.type === 'json') { - JSON.parse(payload.value); - } - return true; - } catch (e: unknown) { - return false; - } - }; - - useEffect(() => { - updateVariant({ - ...variant, - name, - weight: Number(customPercentage ? percentage : 100) * 10, - weightType: customPercentage ? WeightType.FIX : WeightType.VARIABLE, - stickiness: - variants?.length > 0 ? variants[0].stickiness : 'default', - payload: payload.value ? payload : undefined, - overrides: overrides - .map(o => ({ - contextName: o.contextName, - values: o.values, - })) - .filter(o => o.values && o.values.length > 0), - isValid: - isNameNotEmpty(name) && - isNameUnique(name, variant.id) && - isValidPercentage(percentage) && - isValidPayload(payload) && - !apiPayload.error, - }); - }, [name, customPercentage, percentage, payload, overrides]); - - useEffect(() => { - if (!customPercentage) { - setPercentage(String(variant.weight / 10)); - } - }, [variant.weight]); - - return ( - - removeVariant(variant.id)} - disabled={isProtectedVariant(variant)} - > - - - - - 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 - - - - - Add override - - - ); -}; diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/FeatureEnvironmentVariants.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/FeatureEnvironmentVariants.tsx index 314769a3f4..c93c9764a0 100644 --- a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/FeatureEnvironmentVariants.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/FeatureEnvironmentVariants.tsx @@ -15,8 +15,9 @@ import { IFeatureVariant, } from 'interfaces/featureToggle'; import { useMemo, useState } from 'react'; -import { EnvironmentVariantsModal } from './EnvironmentVariantsModal/EnvironmentVariantsModal'; +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'; @@ -26,8 +27,6 @@ import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useCh import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests'; -import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; -import { Edit } from '@mui/icons-material'; const StyledAlert = styled(Alert)(({ theme }) => ({ marginBottom: theme.spacing(4), @@ -63,7 +62,9 @@ export const FeatureEnvironmentVariants = () => { const [searchValue, setSearchValue] = useState(''); const [selectedEnvironment, setSelectedEnvironment] = useState(); + const [selectedVariant, setSelectedVariant] = useState(); const [modalOpen, setModalOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); const environments: IFeatureEnvironmentWithCrEnabled[] = useMemo( () => @@ -90,12 +91,8 @@ export const FeatureEnvironmentVariants = () => { patch: jsonpatch.Operation[]; error?: string; } => { - try { - const updatedNewVariants = updateWeight(newVariants, 1000); - return { patch: createPatch(variants, updatedNewVariants) }; - } catch (error: unknown) { - return { patch: [], error: formatUnknownError(error) }; - } + const updatedNewVariants = updateWeight(newVariants, 1000); + return { patch: createPatch(variants, updatedNewVariants) }; }; const getCrPayload = (variants: IFeatureVariant[]) => ({ @@ -117,21 +114,10 @@ export const FeatureEnvironmentVariants = () => { refetchChangeRequests(); } else { const environmentVariants = environment.variants ?? []; - const { patch, error } = getApiPayload( - environmentVariants, - variants - ); + const { patch } = getApiPayload(environmentVariants, variants); if (patch.length === 0) return; - if (error) { - setToastData({ - type: 'error', - title: error, - }); - return; - } - await patchFeatureEnvironmentVariants( projectId, featureId, @@ -201,20 +187,66 @@ export const FeatureEnvironmentVariants = () => { } }; - const editVariants = (environment: IFeatureEnvironmentWithCrEnabled) => { + const addVariant = (environment: IFeatureEnvironmentWithCrEnabled) => { setSelectedEnvironment(environment); + setSelectedVariant(undefined); setModalOpen(true); }; - const onVariantsConfirm = async (updatedVariants: IFeatureVariant[]) => { + const editVariant = ( + environment: IFeatureEnvironmentWithCrEnabled, + variant: IFeatureVariant + ) => { + setSelectedEnvironment(environment); + setSelectedVariant(variant); + setModalOpen(true); + }; + + const deleteVariant = ( + environment: IFeatureEnvironmentWithCrEnabled, + variant: IFeatureVariant + ) => { + setSelectedEnvironment(environment); + setSelectedVariant(variant); + setDeleteOpen(true); + }; + + const onDeleteConfirm = async () => { + if (selectedEnvironment && selectedVariant) { + const variants = selectedEnvironment.variants ?? []; + + const updatedVariants = variants.filter( + ({ name }) => name !== selectedVariant.name + ); + + try { + await updateVariants(selectedEnvironment, updatedVariants); + setDeleteOpen(false); + setToastData({ + title: selectedEnvironment.crEnabled + ? 'Variant deletion added to draft' + : 'Variant deleted successfully', + type: 'success', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + } + }; + + const onVariantConfirm = async (updatedVariants: IFeatureVariant[]) => { if (selectedEnvironment) { try { await updateVariants(selectedEnvironment, updatedVariants); setModalOpen(false); setToastData({ title: selectedEnvironment.crEnabled - ? `Variant changes added to draft` - : 'Variants updated successfully', + ? `Variant ${ + selectedVariant ? 'changes' : '' + } added to draft` + : `Variant ${ + selectedVariant ? 'updated' : 'added' + } successfully`, type: 'success', }); } catch (error: unknown) { @@ -241,6 +273,23 @@ export const FeatureEnvironmentVariants = () => { } }; + const onUpdateStickiness = async ( + environment: IFeatureEnvironmentWithCrEnabled, + updatedVariants: IFeatureVariant[] + ) => { + try { + await updateVariants(environment, updatedVariants); + setToastData({ + title: environment.crEnabled + ? 'Variant stickiness update added to draft' + : 'Variant stickiness updated successfully', + type: 'success', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + return ( { key={environment.name} environment={environment} searchValue={searchValue} + onEditVariant={(variant: IFeatureVariant) => + editVariant(environment, variant) + } + onDeleteVariant={(variant: IFeatureVariant) => + deleteVariant(environment, variant) + } + onUpdateStickiness={(variants: IFeatureVariant[]) => + onUpdateStickiness(environment, variants) + } > { onCopyVariantsFrom={onCopyVariantsFrom} otherEnvsWithVariants={otherEnvsWithVariants} /> - - editVariants(environment) - } - permission={ - UPDATE_FEATURE_ENVIRONMENT_VARIANTS - } - projectId={projectId} - environmentId={environment.name} - > - - - } - elseShow={ - - editVariants(environment) - } - variant="outlined" - permission={ - UPDATE_FEATURE_ENVIRONMENT_VARIANTS - } - projectId={projectId} - environmentId={environment.name} - > - Add variant - - } - /> + addVariant(environment)} + variant="outlined" + permission={UPDATE_FEATURE_ENVIRONMENT_VARIANTS} + projectId={projectId} + environmentId={environment.name} + > + 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..f4c1303781 --- /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/yarn.lock b/frontend/yarn.lock index 5cc2b88dfe..1e22aa32f8 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2607,11 +2607,6 @@ resolved "https://registry.yarnpkg.com/@types/urijs/-/urijs-1.19.19.tgz#2789369799907fc11e2bc6e3a00f6478c2281b95" integrity sha512-FDJNkyhmKLw7uEvTxx5tSXfPeQpO0iy73Ry+PmYZJvQy0QIWX8a7kJ4kLWRf+EbTPJEPDSgPXHaM7pzr5lmvCg== -"@types/uuid@^9.0.0": - version "9.0.0" - resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.0.tgz#53ef263e5239728b56096b0a869595135b7952d2" - integrity sha512-kr90f+ERiQtKWMz5rP32ltJ/BtULDI5RVO0uavn1HQUOwjx0R1h0rnDYNL0CepF1zL5bSY6FISAfd9tOdDhU5Q== - "@types/yargs-parser@*": version "21.0.0" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b"