From 4d1a004b5d7466252cd52f7e2f4f59a8c34d8275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Wed, 25 Jan 2023 14:10:35 +0000 Subject: [PATCH] feat: adds CR to variants per env UI (#2989) https://linear.app/unleash/issue/2-585/add-cr-to-variants-per-environment-ui Adds CR to the variants per environment UI. This is basically the point where we have CRs integrated but can e.g. only update the weight once per CR. Adapting the UI to better fit CR logic will come next. ![image](https://user-images.githubusercontent.com/14320932/214563512-664a432f-f2eb-49f7-9721-cbd6785a9320.png) --- .../ChangeRequestConfirmDialog.tsx | 2 +- .../EnvironmentVariantModal.tsx | 77 ++++++++- .../FeatureEnvironmentVariants.tsx | 157 ++++++++++++++---- .../PushVariantsButton/PushVariantsButton.tsx | 32 ++-- .../useChangeRequestApi.ts | 3 +- frontend/src/interfaces/featureToggle.ts | 4 + frontend/src/interfaces/uiConfig.ts | 1 + 7 files changed, 220 insertions(+), 56 deletions(-) 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 {