diff --git a/frontend/src/component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestConfirmDialog.tsx b/frontend/src/component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestConfirmDialog.tsx index 1a9b8cc7ff..4812511c3b 100644 --- a/frontend/src/component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestConfirmDialog.tsx +++ b/frontend/src/component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestConfirmDialog.tsx @@ -54,7 +54,7 @@ export const ChangeRequestDialogue: FC = ({ show={ Change requests feature is enabled for {environment}. - Your changes needs to be approved before they will be + 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. diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/EnvironmentVariantModal.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/EnvironmentVariantModal.tsx index b369d0bd43..dfef591e27 100644 --- a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/EnvironmentVariantModal.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantModal/EnvironmentVariantModal.tsx @@ -28,6 +28,9 @@ 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', @@ -99,6 +102,10 @@ const StyledSelectMenu = styled(SelectMenu)(({ theme }) => ({ marginRight: theme.spacing(10), })); +const StyledCRAlert = styled(Alert)(({ theme }) => ({ + marginBottom: theme.spacing(2), +})); + const StyledAlert = styled(Alert)(({ theme }) => ({ marginTop: theme.spacing(4), })); @@ -147,6 +154,11 @@ interface IEnvironmentVariantModalProps { variants: IFeatureVariant[], newVariants: IFeatureVariant[] ) => { patch: Operation[]; error?: string }; + getCrPayload: (variants: IFeatureVariant[]) => { + feature: string; + action: 'patchVariant'; + payload: { variants: IFeatureVariant[] }; + }; onConfirm: (updatedVariants: IFeatureVariant[]) => void; } @@ -156,6 +168,7 @@ export const EnvironmentVariantModal = ({ open, setOpen, getApiPayload, + getCrPayload, onConfirm, }: IEnvironmentVariantModalProps) => { const projectId = useRequiredPathParam('projectId'); @@ -163,6 +176,11 @@ export const EnvironmentVariantModal = ({ 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(''); @@ -237,6 +255,7 @@ export const EnvironmentVariantModal = ({ }; const apiPayload = getApiPayload(variants, getUpdatedVariants()); + const crPayload = getCrPayload(getUpdatedVariants()); useEffect(() => { clearError(ErrorField.PERCENTAGE); @@ -255,11 +274,21 @@ export const EnvironmentVariantModal = ({ onConfirm(getUpdatedVariants()); }; - const formatApiCode = () => `curl --location --request PATCH '${ - uiConfig.unleashUrl - }/api/admin/projects/${projectId}/features/${featureId}/environments/${ - environment?.name - }/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)}'`; @@ -324,6 +353,15 @@ export const EnvironmentVariantModal = ({ } }; + const hasChangeRequestInReviewForEnvironment = + changeRequestInReviewOrApproved(environment?.name || ''); + + const changeRequestButtonText = hasChangeRequestInReviewForEnvironment + ? 'Add to existing change request' + : 'Add change to draft'; + + const isChangeRequest = isChangeRequestConfigured(environment?.name || ''); + return (
+ + 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 @@ -486,7 +547,11 @@ export const EnvironmentVariantModal = ({ color="primary" disabled={!isValid} > - {editing ? 'Save' : 'Add'} variant + {isChangeRequest + ? changeRequestButtonText + : editing + ? 'Save variant' + : 'Add variant'} { diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/FeatureEnvironmentVariants.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/FeatureEnvironmentVariants.tsx index 60180371da..c93c9764a0 100644 --- a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/FeatureEnvironmentVariants.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/FeatureEnvironmentVariants.tsx @@ -10,8 +10,11 @@ import { updateWeight } from 'component/common/util'; import { UPDATE_FEATURE_ENVIRONMENT_VARIANTS } from 'component/providers/AccessProvider/permissions'; import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; -import { IFeatureEnvironment, IFeatureVariant } from 'interfaces/featureToggle'; -import { useState } from 'react'; +import { + IFeatureEnvironmentWithCrEnabled, + IFeatureVariant, +} from 'interfaces/featureToggle'; +import { useMemo, useState } from 'react'; import { EnvironmentVariantModal } from './EnvironmentVariantModal/EnvironmentVariantModal'; import { EnvironmentVariantsCard } from './EnvironmentVariantsCard/EnvironmentVariantsCard'; import { VariantDeleteDialog } from './VariantDeleteDialog/VariantDeleteDialog'; @@ -20,6 +23,10 @@ import { formatUnknownError } from 'utils/formatUnknownError'; import useToast from 'hooks/useToast'; import { EnvironmentVariantsCopyFrom } from './EnvironmentVariantsCopyFrom/EnvironmentVariantsCopyFrom'; import { PushVariantsButton } from './PushVariantsButton/PushVariantsButton'; +import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; +import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests'; const StyledAlert = styled(Alert)(({ theme }) => ({ marginBottom: theme.spacing(4), @@ -34,6 +41,7 @@ const StyledButtonContainer = styled('div')(({ theme }) => ({ })); export const FeatureEnvironmentVariants = () => { + const { uiConfig } = useUiConfig(); const { setToastData, setToastApiError } = useToast(); const theme = useTheme(); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); @@ -46,14 +54,29 @@ export const FeatureEnvironmentVariants = () => { ); const { patchFeatureEnvironmentVariants, overrideVariantsInEnvironments } = useFeatureApi(); + const { refetch: refetchChangeRequests } = + usePendingChangeRequests(projectId); + const { addChange } = useChangeRequestApi(); + const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); const [searchValue, setSearchValue] = useState(''); const [selectedEnvironment, setSelectedEnvironment] = - useState(); + useState(); const [selectedVariant, setSelectedVariant] = useState(); const [modalOpen, setModalOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); + const environments: IFeatureEnvironmentWithCrEnabled[] = useMemo( + () => + feature?.environments?.map(environment => ({ + ...environment, + crEnabled: + uiConfig.flags.crOnVariants && + isChangeRequestConfigured(environment.name), + })) || [], + [feature.environments, uiConfig.flags.crOnVariants] + ); + const createPatch = ( variants: IFeatureVariant[], newVariants: IFeatureVariant[] @@ -72,38 +95,91 @@ export const FeatureEnvironmentVariants = () => { return { patch: createPatch(variants, updatedNewVariants) }; }; + const getCrPayload = (variants: IFeatureVariant[]) => ({ + feature: featureId, + action: 'patchVariant' as const, + payload: { variants }, + }); + const updateVariants = async ( - environment: IFeatureEnvironment, + environment: IFeatureEnvironmentWithCrEnabled, variants: IFeatureVariant[] ) => { - const environmentVariants = environment.variants ?? []; - const { patch } = getApiPayload(environmentVariants, variants); + if (environment.crEnabled) { + await addChange( + projectId, + environment.name, + getCrPayload(variants) + ); + refetchChangeRequests(); + } else { + const environmentVariants = environment.variants ?? []; + const { patch } = getApiPayload(environmentVariants, variants); - if (patch.length === 0) return; + if (patch.length === 0) return; - await patchFeatureEnvironmentVariants( - projectId, - featureId, - environment.name, - patch - ); + await patchFeatureEnvironmentVariants( + projectId, + featureId, + environment.name, + patch + ); + } refetchFeature(); }; const pushToEnvironments = async ( variants: IFeatureVariant[], - selected: string[] + selected: IFeatureEnvironmentWithCrEnabled[] ) => { try { - await overrideVariantsInEnvironments( - projectId, - featureId, - variants, - selected + const selectedWithCrEnabled = selected.filter( + ({ crEnabled }) => crEnabled ); + const selectedWithCrDisabled = selected.filter( + ({ crEnabled }) => !crEnabled + ); + + if (selectedWithCrEnabled.length) { + await Promise.all( + selectedWithCrEnabled.map(environment => + addChange( + projectId, + environment.name, + getCrPayload(variants) + ) + ) + ); + } + if (selectedWithCrDisabled.length) { + await overrideVariantsInEnvironments( + projectId, + featureId, + variants, + selectedWithCrDisabled.map(({ name }) => name) + ); + } + refetchChangeRequests(); refetchFeature(); + const pushTitle = selectedWithCrDisabled.length + ? `Variants pushed to ${ + selectedWithCrDisabled.length === 1 + ? selectedWithCrDisabled[0].name + : `${selectedWithCrDisabled.length} environments` + }` + : ''; + const draftTitle = selectedWithCrEnabled.length + ? `Variants push added to ${ + selectedWithCrEnabled.length === 1 + ? `${selectedWithCrEnabled[0].name} draft` + : `${selectedWithCrEnabled.length} drafts` + }` + : ''; + const title = `${pushTitle}${ + pushTitle && draftTitle ? '. ' : '' + }${draftTitle}`; setToastData({ - title: `Variants pushed successfully`, + title, type: 'success', }); } catch (error: unknown) { @@ -111,14 +187,14 @@ export const FeatureEnvironmentVariants = () => { } }; - const addVariant = (environment: IFeatureEnvironment) => { + const addVariant = (environment: IFeatureEnvironmentWithCrEnabled) => { setSelectedEnvironment(environment); setSelectedVariant(undefined); setModalOpen(true); }; const editVariant = ( - environment: IFeatureEnvironment, + environment: IFeatureEnvironmentWithCrEnabled, variant: IFeatureVariant ) => { setSelectedEnvironment(environment); @@ -127,7 +203,7 @@ export const FeatureEnvironmentVariants = () => { }; const deleteVariant = ( - environment: IFeatureEnvironment, + environment: IFeatureEnvironmentWithCrEnabled, variant: IFeatureVariant ) => { setSelectedEnvironment(environment); @@ -147,7 +223,9 @@ export const FeatureEnvironmentVariants = () => { await updateVariants(selectedEnvironment, updatedVariants); setDeleteOpen(false); setToastData({ - title: `Variant deleted successfully`, + title: selectedEnvironment.crEnabled + ? 'Variant deletion added to draft' + : 'Variant deleted successfully', type: 'success', }); } catch (error: unknown) { @@ -162,9 +240,13 @@ export const FeatureEnvironmentVariants = () => { await updateVariants(selectedEnvironment, updatedVariants); setModalOpen(false); setToastData({ - title: `Variant ${ - selectedVariant ? 'updated' : 'added' - } successfully`, + title: selectedEnvironment.crEnabled + ? `Variant ${ + selectedVariant ? 'changes' : '' + } added to draft` + : `Variant ${ + selectedVariant ? 'updated' : 'added' + } successfully`, type: 'success', }); } catch (error: unknown) { @@ -174,14 +256,16 @@ export const FeatureEnvironmentVariants = () => { }; const onCopyVariantsFrom = async ( - fromEnvironment: IFeatureEnvironment, - toEnvironment: IFeatureEnvironment + fromEnvironment: IFeatureEnvironmentWithCrEnabled, + toEnvironment: IFeatureEnvironmentWithCrEnabled ) => { try { const variants = fromEnvironment.variants ?? []; await updateVariants(toEnvironment, variants); setToastData({ - title: 'Variants copied successfully', + title: toEnvironment.crEnabled + ? 'Variants copy added to draft' + : 'Variants copied successfully', type: 'success', }); } catch (error: unknown) { @@ -190,13 +274,15 @@ export const FeatureEnvironmentVariants = () => { }; const onUpdateStickiness = async ( - environment: IFeatureEnvironment, + environment: IFeatureEnvironmentWithCrEnabled, updatedVariants: IFeatureVariant[] ) => { try { await updateVariants(environment, updatedVariants); setToastData({ - title: 'Variant stickiness updated successfully', + title: environment.crEnabled + ? 'Variant stickiness update added to draft' + : 'Variant stickiness updated successfully', type: 'success', }); } catch (error: unknown) { @@ -242,8 +328,8 @@ export const FeatureEnvironmentVariants = () => { variants you should use the getVariant() method in the Client SDK. - {feature.environments.map(environment => { - const otherEnvsWithVariants = feature.environments.filter( + {environments.map(environment => { + const otherEnvsWithVariants = environments.filter( ({ name, variants }) => name !== environment.name && variants?.length ); @@ -266,7 +352,7 @@ export const FeatureEnvironmentVariants = () => { @@ -303,6 +389,7 @@ export const FeatureEnvironmentVariants = () => { open={modalOpen} setOpen={setModalOpen} getApiPayload={getApiPayload} + getCrPayload={getCrPayload} onConfirm={onVariantConfirm} /> ({ interface IPushVariantsButtonProps { current: string; - environments: IFeatureEnvironment[]; + environments: IFeatureEnvironmentWithCrEnabled[]; permission: string; projectId: string; - onSubmit: (selected: string[]) => void; + onSubmit: (selected: IFeatureEnvironmentWithCrEnabled[]) => void; } export const PushVariantsButton = ({ @@ -48,9 +48,9 @@ export const PushVariantsButton = ({ ); const pushToOpen = Boolean(pushToAnchorEl); - const [selectedEnvironments, setSelectedEnvironments] = useState( - [] - ); + const [selectedEnvironments, setSelectedEnvironments] = useState< + IFeatureEnvironmentWithCrEnabled[] + >([]); const hasAccess = useCheckProjectAccess(projectId); const hasAccessTo = environments.reduce((acc, env) => { @@ -58,16 +58,22 @@ export const PushVariantsButton = ({ return acc; }, {} as Record); - const addSelectedEnvironment = (name: string) => { + const addSelectedEnvironment = ( + environment: IFeatureEnvironmentWithCrEnabled + ) => { setSelectedEnvironments(prevSelectedEnvironments => [ ...prevSelectedEnvironments, - name, + environment, ]); }; - const removeSelectedEnvironment = (name: string) => { + const removeSelectedEnvironment = ( + environment: IFeatureEnvironmentWithCrEnabled + ) => { setSelectedEnvironments(prevSelectedEnvironments => - prevSelectedEnvironments.filter(env => env !== name) + prevSelectedEnvironments.filter( + ({ name }) => name !== environment.name + ) ); }; @@ -121,16 +127,16 @@ export const PushVariantsButton = ({ onChange={event => { if (event.target.checked) { addSelectedEnvironment( - otherEnvironment.name + otherEnvironment ); } else { removeSelectedEnvironment( - otherEnvironment.name + otherEnvironment ); } }} checked={selectedEnvironments.includes( - otherEnvironment.name + otherEnvironment )} value={otherEnvironment.name} /> diff --git a/frontend/src/hooks/api/actions/useChangeRequestApi/useChangeRequestApi.ts b/frontend/src/hooks/api/actions/useChangeRequestApi/useChangeRequestApi.ts index 5e926104d2..cb31e57f68 100644 --- a/frontend/src/hooks/api/actions/useChangeRequestApi/useChangeRequestApi.ts +++ b/frontend/src/hooks/api/actions/useChangeRequestApi/useChangeRequestApi.ts @@ -7,7 +7,8 @@ export interface IChangeSchema { | 'updateEnabled' | 'addStrategy' | 'updateStrategy' - | 'deleteStrategy'; + | 'deleteStrategy' + | 'patchVariant'; payload: string | boolean | object | number; } diff --git a/frontend/src/interfaces/featureToggle.ts b/frontend/src/interfaces/featureToggle.ts index c28097ca44..b205ba8981 100644 --- a/frontend/src/interfaces/featureToggle.ts +++ b/frontend/src/interfaces/featureToggle.ts @@ -44,6 +44,10 @@ export interface IFeatureEnvironment { variants?: IFeatureVariant[]; } +export interface IFeatureEnvironmentWithCrEnabled extends IFeatureEnvironment { + crEnabled?: boolean; +} + export interface IFeatureVariant { name: string; stickiness: string; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 7a0f96bcde..fd13793794 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -47,6 +47,7 @@ export interface IFlags { featuresExportImport?: boolean; newProjectOverview?: boolean; caseInsensitiveInOperators?: boolean; + crOnVariants?: boolean; } export interface IVersionInfo {