From 816c8dbb46ed3c28625213fc2067e8291a4653e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Fri, 27 Jan 2023 08:13:57 +0000 Subject: [PATCH] feat: new variants per env form (#3004) https://linear.app/unleash/issue/2-647/adapt-current-variants-ui-to-better-align-with-cr ## About the changes Big refactor to the variants per environment UI/UX logic, making the variants management happen on a side modal. This makes it so variants per environment play a lot nicer with change requests. ![image](https://user-images.githubusercontent.com/14320932/214972213-32b9aba9-1390-47b3-a00a-8c4ada359953.png) Relates to [roadmap](https://github.com/orgs/Unleash/projects/10) item: [#2254](https://github.com/Unleash/unleash/issues/2254) ### Important files A big chunk of the changes is mostly moving things around or straight up removing them. - EnvironmentVariantModal - The modal itself that controls all of the variants that you're editing; - VariantForm - The extracted form for editing each of the variants; --- 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 ------------------ .../EnvironmentVariantsCard.tsx | 63 +- .../EnvironmentVariantsTable.tsx | 47 +- .../VariantsActionsCell.tsx | 57 -- .../EnvironmentVariantsModal.tsx | 424 +++++++++++++ .../VariantForm/VariantForm.tsx | 417 +++++++++++++ .../VariantOverrides/VariantOverrides.tsx | 0 .../VariantOverrides/useOverrides.test.ts | 0 .../VariantOverrides/useOverrides.ts | 0 .../FeatureEnvironmentVariants.tsx | 162 ++--- .../VariantDeleteDialog.tsx | 42 -- frontend/yarn.lock | 5 + 16 files changed, 964 insertions(+), 864 deletions(-) delete mode 100644 frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/EnvironmentVariantModal.tsx delete mode 100644 frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/VariantsActionsCell/VariantsActionsCell.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/EnvironmentVariantsModal.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm/VariantForm.tsx rename frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/{EnvironmentVariantModal => EnvironmentVariantsModal/VariantForm}/VariantOverrides/VariantOverrides.tsx (100%) rename frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/{EnvironmentVariantModal => EnvironmentVariantsModal/VariantForm}/VariantOverrides/useOverrides.test.ts (100%) rename frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/{EnvironmentVariantModal => EnvironmentVariantsModal/VariantForm}/VariantOverrides/useOverrides.ts (100%) delete mode 100644 frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/VariantDeleteDialog/VariantDeleteDialog.tsx diff --git a/frontend/package.json b/frontend/package.json index bff0637c32..635224f9df 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -57,6 +57,7 @@ "@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 a5cd6824e1..2a2944a01b 100644 --- a/frontend/src/component/common/Input/Input.styles.ts +++ b/frontend/src/component/common/Input/Input.styles.ts @@ -4,5 +4,9 @@ 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 d05ce5b1d6..82ed384f4d 100644 --- a/frontend/src/component/common/Input/Input.tsx +++ b/frontend/src/component/common/Input/Input.tsx @@ -48,6 +48,7 @@ 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 cafde7c8c8..ad28583e0f 100644 --- a/frontend/src/component/common/util.ts +++ b/frontend/src/component/common/util.ts @@ -3,6 +3,7 @@ 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) => { @@ -90,6 +91,40 @@ 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 deleted file mode 100644 index f96b8fe8f7..0000000000 --- a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/EnvironmentVariantModal.tsx +++ /dev/null @@ -1,570 +0,0 @@ -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/EnvironmentVariantsCard/EnvironmentVariantsCard.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsCard.tsx index fa8ccf7a9c..96bbd44455 100644 --- a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsCard.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsCard.tsx @@ -1,11 +1,9 @@ import { CloudCircle } from '@mui/icons-material'; import { styled } from '@mui/material'; -import { IFeatureEnvironment, IFeatureVariant } from 'interfaces/featureToggle'; +import { IFeatureEnvironment } 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'; +import { Badge } from 'component/common/Badge/Badge'; const StyledCard = styled('div')(({ theme }) => ({ padding: theme.spacing(3), @@ -16,7 +14,7 @@ const StyledCard = styled('div')(({ theme }) => ({ }, })); -const StyledHeader = styled('div')(() => ({ +const StyledHeader = styled('div')({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', @@ -24,7 +22,7 @@ const StyledHeader = styled('div')(() => ({ display: 'flex', alignItems: 'center', }, -})); +}); const StyledCloudCircle = styled(CloudCircle, { shouldForwardProp: prop => prop !== 'deprecated', @@ -41,6 +39,7 @@ 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 }) => ({ @@ -49,57 +48,27 @@ const StyledDescription = styled('p')(({ theme }) => ({ marginBottom: theme.spacing(1.5), })); -const StyledGeneralSelect = styled(GeneralSelect)(({ theme }) => ({ - minWidth: theme.spacing(20), +const StyledStickinessContainer = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1.5), + marginBottom: theme.spacing(0.5), })); 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 ( @@ -118,14 +87,15 @@ export const EnvironmentVariantsCard = ({ 1} show={ <> -

Stickiness

+ +

Stickiness:

+ {stickiness} +
By overriding the stickiness you can control which parameter is used to @@ -139,11 +109,6 @@ 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 c431d5cc33..f288f70977 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,7 +20,6 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useSearch } from 'hooks/useSearch'; import { IFeatureEnvironment, - IFeatureVariant, IOverride, IPayload, } from 'interfaces/featureToggle'; @@ -29,9 +28,7 @@ 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), @@ -40,15 +37,11 @@ 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'); @@ -108,30 +101,11 @@ export const EnvironmentVariantsTable = ({ }, { Header: 'Type', - accessor: 'weightType', + accessor: (row: any) => + row.weightType === 'fix' ? 'Fixed' : 'Variable', Cell: TextCell, sortType: 'alphanumeric', }, - { - Header: 'Actions', - id: 'Actions', - align: 'center', - Cell: ({ - row: { original }, - }: { - row: { original: IFeatureVariant }; - }) => ( - - ), - disableSortBy: true, - }, ], [projectId, variants, environment] ); @@ -143,23 +117,6 @@ 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 deleted file mode 100644 index e242030370..0000000000 --- a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/VariantsActionsCell/VariantsActionsCell.tsx +++ /dev/null @@ -1,57 +0,0 @@ -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 new file mode 100644 index 0000000000..07a6ed9409 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/EnvironmentVariantsModal.tsx @@ -0,0 +1,424 @@ +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 new file mode 100644 index 0000000000..6caf70b213 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm/VariantForm.tsx @@ -0,0 +1,417 @@ +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/EnvironmentVariantModal/VariantOverrides/VariantOverrides.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm/VariantOverrides/VariantOverrides.tsx similarity index 100% rename from frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/VariantOverrides.tsx rename to frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm/VariantOverrides/VariantOverrides.tsx diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/useOverrides.test.ts b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm/VariantOverrides/useOverrides.test.ts similarity index 100% rename from frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/useOverrides.test.ts rename to frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm/VariantOverrides/useOverrides.test.ts diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/useOverrides.ts b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm/VariantOverrides/useOverrides.ts similarity index 100% rename from frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/VariantOverrides/useOverrides.ts rename to frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/VariantForm/VariantOverrides/useOverrides.ts diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/FeatureEnvironmentVariants.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/FeatureEnvironmentVariants.tsx index c93c9764a0..314769a3f4 100644 --- a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/FeatureEnvironmentVariants.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/FeatureEnvironmentVariants.tsx @@ -15,9 +15,8 @@ import { IFeatureVariant, } from 'interfaces/featureToggle'; import { useMemo, useState } from 'react'; -import { EnvironmentVariantModal } from './EnvironmentVariantModal/EnvironmentVariantModal'; +import { EnvironmentVariantsModal } from './EnvironmentVariantsModal/EnvironmentVariantsModal'; 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'; @@ -27,6 +26,8 @@ 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), @@ -62,9 +63,7 @@ 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( () => @@ -91,8 +90,12 @@ export const FeatureEnvironmentVariants = () => { patch: jsonpatch.Operation[]; error?: string; } => { - const updatedNewVariants = updateWeight(newVariants, 1000); - return { patch: createPatch(variants, updatedNewVariants) }; + try { + const updatedNewVariants = updateWeight(newVariants, 1000); + return { patch: createPatch(variants, updatedNewVariants) }; + } catch (error: unknown) { + return { patch: [], error: formatUnknownError(error) }; + } }; const getCrPayload = (variants: IFeatureVariant[]) => ({ @@ -114,10 +117,21 @@ export const FeatureEnvironmentVariants = () => { refetchChangeRequests(); } else { const environmentVariants = environment.variants ?? []; - const { patch } = getApiPayload(environmentVariants, variants); + const { patch, error } = getApiPayload( + environmentVariants, + variants + ); if (patch.length === 0) return; + if (error) { + setToastData({ + type: 'error', + title: error, + }); + return; + } + await patchFeatureEnvironmentVariants( projectId, featureId, @@ -187,66 +201,20 @@ export const FeatureEnvironmentVariants = () => { } }; - const addVariant = (environment: IFeatureEnvironmentWithCrEnabled) => { + const editVariants = (environment: IFeatureEnvironmentWithCrEnabled) => { setSelectedEnvironment(environment); - setSelectedVariant(undefined); setModalOpen(true); }; - 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[]) => { + const onVariantsConfirm = async (updatedVariants: IFeatureVariant[]) => { if (selectedEnvironment) { try { await updateVariants(selectedEnvironment, updatedVariants); setModalOpen(false); setToastData({ title: selectedEnvironment.crEnabled - ? `Variant ${ - selectedVariant ? 'changes' : '' - } added to draft` - : `Variant ${ - selectedVariant ? 'updated' : 'added' - } successfully`, + ? `Variant changes added to draft` + : 'Variants updated successfully', type: 'success', }); } catch (error: unknown) { @@ -273,23 +241,6 @@ 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} /> - addVariant(environment)} - variant="outlined" - permission={UPDATE_FEATURE_ENVIRONMENT_VARIANTS} - projectId={projectId} - environmentId={environment.name} - > - Add variant - + + 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 + + } + /> ); })} - - ); diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/VariantDeleteDialog/VariantDeleteDialog.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/VariantDeleteDialog/VariantDeleteDialog.tsx deleted file mode 100644 index f4c1303781..0000000000 --- a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/VariantDeleteDialog/VariantDeleteDialog.tsx +++ /dev/null @@ -1,42 +0,0 @@ -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 1e22aa32f8..5cc2b88dfe 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2607,6 +2607,11 @@ 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"