From 514961632f2b7704e413ddaa058a33d644822881 Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Mon, 24 Apr 2023 16:32:19 +0300 Subject: [PATCH] feat: edit change requests (#3573) --- .../ChangeRequest/ChangeRequest.tsx | 9 +- .../Changes/Change/ChangeActions.tsx | 200 ++++++++++++++++++ .../ChangeRequest/Changes/Change/Discard.tsx | 74 ------- .../Changes/Change/EditChange.tsx | 199 +++++++++++++++++ .../EnvironmentChangeRequestTitle.test.tsx | 4 +- .../ChangeRequestsTabs/ChangeRequestsTabs.tsx | 15 +- .../changeRequest/changeRequest.types.ts | 2 +- .../common/FormTemplate/FormTemplate.tsx | 1 - .../common/SidebarModal/SidebarModal.tsx | 3 + .../FeatureStrategyForm.tsx | 6 +- .../useChangeRequestApi.ts | 23 +- 11 files changed, 447 insertions(+), 89 deletions(-) create mode 100644 frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeActions.tsx delete mode 100644 frontend/src/component/changeRequest/ChangeRequest/Changes/Change/Discard.tsx create mode 100644 frontend/src/component/changeRequest/ChangeRequest/Changes/Change/EditChange.tsx diff --git a/frontend/src/component/changeRequest/ChangeRequest/ChangeRequest.tsx b/frontend/src/component/changeRequest/ChangeRequest/ChangeRequest.tsx index c7a478dd72..340345ca7b 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/ChangeRequest.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/ChangeRequest.tsx @@ -3,7 +3,7 @@ import { Box, Typography } from '@mui/material'; import type { IChangeRequest } from '../changeRequest.types'; import { FeatureToggleChanges } from './Changes/FeatureToggleChanges'; import { Change } from './Changes/Change/Change'; -import { DiscardContainer } from './Changes/Change/Discard'; +import { ChangeActions } from './Changes/Change/ChangeActions'; interface IChangeRequestProps { changeRequest: IChangeRequest; @@ -30,10 +30,11 @@ export const ChangeRequest: VFC = ({ } index={index} diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeActions.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeActions.tsx new file mode 100644 index 0000000000..6d6e3b7bd7 --- /dev/null +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeActions.tsx @@ -0,0 +1,200 @@ +import React, { FC, useState } from 'react'; +import { + IChange, + IChangeRequest, + IChangeRequestAddStrategy, + IChangeRequestUpdateStrategy, +} from '../../../changeRequest.types'; +import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; +import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser'; +import { changesCount } from '../../../changesCount'; +import { + Box, + IconButton, + Link, + ListItemIcon, + ListItemText, + MenuItem, + MenuList, + Popover, + styled, + Tooltip, + Typography, +} from '@mui/material'; +import { Delete, Edit, GroupRounded, MoreVert } from '@mui/icons-material'; +import { EditChange } from './EditChange'; + +const useShowActions = (changeRequest: IChangeRequest, change: IChange) => { + const { isChangeRequestConfigured } = useChangeRequestsEnabled( + changeRequest.project + ); + const allowChangeRequestActions = isChangeRequestConfigured( + changeRequest.environment + ); + const isPending = !['Cancelled', 'Applied'].includes(changeRequest.state); + + const { user } = useAuthUser(); + const isAuthor = user?.id === changeRequest.createdBy.id; + + const showActions = allowChangeRequestActions && isPending && isAuthor; + + const showEdit = + showActions && + ['addStrategy', 'updateStrategy'].includes(change.action); + + const showDiscard = showActions && changesCount(changeRequest) > 1; + + return { showEdit, showDiscard }; +}; + +const StyledPopover = styled(Popover)(({ theme }) => ({ + borderRadius: theme.shape.borderRadiusLarge, + padding: theme.spacing(1, 1.5), +})); + +export const ChangeActions: FC<{ + changeRequest: IChangeRequest; + feature: string; + change: IChange; + onRefetch?: () => void; +}> = ({ changeRequest, feature, change, onRefetch }) => { + const { showDiscard, showEdit } = useShowActions(changeRequest, change); + const { discardChange } = useChangeRequestApi(); + const { setToastData, setToastApiError } = useToast(); + + const [editOpen, setEditOpen] = useState(false); + + const id = `cr-${change.id}-actions`; + const menuId = `${id}-menu`; + + const [anchorEl, setAnchorEl] = useState(null); + + const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + + const onEdit = () => { + setEditOpen(true); + }; + + const onDiscard = async () => { + try { + handleClose(); + await discardChange( + changeRequest.project, + changeRequest.id, + change.id + ); + setToastData({ + title: 'Change discarded from change request draft.', + type: 'success', + }); + onRefetch?.(); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + return ( + + + + + + + + + + + + + + + Edit change + + + { + setEditOpen(false); + onRefetch?.(); + }} + onClose={() => { + setEditOpen(false); + }} + /> + + } + /> + + { + onDiscard(); + }} + > + + + + + + Discard change + + + + } + /> + + + + } + /> + ); +}; diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/Discard.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/Discard.tsx deleted file mode 100644 index e30177946c..0000000000 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/Discard.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React, { FC } from 'react'; -import { IChangeRequest } from '../../../changeRequest.types'; -import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi'; -import useToast from 'hooks/useToast'; -import { formatUnknownError } from 'utils/formatUnknownError'; -import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; -import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; -import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser'; -import { changesCount } from '../../../changesCount'; -import { Box, Link, styled } from '@mui/material'; - -const useShowDiscard = (changeRequest: IChangeRequest) => { - const { isChangeRequestConfigured } = useChangeRequestsEnabled( - changeRequest.project - ); - const allowChangeRequestActions = isChangeRequestConfigured( - changeRequest.environment - ); - const isPending = !['Cancelled', 'Applied'].includes(changeRequest.state); - - const { user } = useAuthUser(); - const isAuthor = user?.id === changeRequest.createdBy.id; - - const showDiscard = - allowChangeRequestActions && - isPending && - isAuthor && - changesCount(changeRequest) > 1; - - return showDiscard; -}; - -const StyledLink = styled(Link)(() => ({ - textDecoration: 'none', - '&:hover, &:focus': { - textDecoration: 'underline', - }, -})); - -const Discard: FC<{ onDiscard: () => void }> = ({ onDiscard }) => ( - - Discard - -); - -export const DiscardContainer: FC<{ - changeRequest: IChangeRequest; - changeId: number; - onPostDiscard?: () => void; -}> = ({ changeRequest, changeId, onPostDiscard }) => { - const showDiscard = useShowDiscard(changeRequest); - const { discardChange } = useChangeRequestApi(); - const { setToastData, setToastApiError } = useToast(); - - const onDiscard = (id: number) => async () => { - try { - await discardChange(changeRequest.project, changeRequest.id, id); - setToastData({ - title: 'Change discarded from change request draft.', - type: 'success', - }); - onPostDiscard?.(); - } catch (error: unknown) { - setToastApiError(formatUnknownError(error)); - } - }; - - return ( - } - /> - ); -}; diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/EditChange.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/EditChange.tsx new file mode 100644 index 0000000000..c8d8b8ddd6 --- /dev/null +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/EditChange.tsx @@ -0,0 +1,199 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { FeatureStrategyForm } from 'component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm'; +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import useToast from 'hooks/useToast'; +import { IFeatureStrategy } from 'interfaces/strategy'; +import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions'; +import { ISegment } from 'interfaces/segment'; +import { formatStrategyName } from 'utils/strategyNames'; +import { useFormErrors } from 'hooks/useFormErrors'; +import { useCollaborateData } from 'hooks/useCollaborateData'; +import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; +import { IFeatureToggle } from 'interfaces/featureToggle'; +import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; +import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi'; +import { comparisonModerator } from 'component/feature/FeatureStrategy/featureStrategy.utils'; +import { + IChangeRequestAddStrategy, + IChangeRequestUpdateStrategy, +} from 'component/changeRequest/changeRequest.types'; +import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; +import { useSegments } from 'hooks/api/getters/useSegments/useSegments'; + +interface IEditChangeProps { + change: IChangeRequestAddStrategy | IChangeRequestUpdateStrategy; + changeRequestId: number; + featureId: string; + environment: string; + open: boolean; + onSubmit: () => void; + onClose: () => void; +} + +export const EditChange = ({ + change, + changeRequestId, + environment, + open, + onSubmit, + onClose, + featureId, +}: IEditChangeProps) => { + const projectId = useRequiredPathParam('projectId'); + const { editChange } = useChangeRequestApi(); + + const [strategy, setStrategy] = useState>( + change.payload + ); + + const { segments: allSegments } = useSegments(); + const strategySegments = + allSegments?.filter(segment => { + return change.payload.segments?.includes(segment.id); + }) || []; + + const [segments, setSegments] = useState(strategySegments); + + const strategyDefinition = { + parameters: change.payload.parameters, + name: change.payload.name, + }; + const { setToastData, setToastApiError } = useToast(); + const errors = useFormErrors(); + const { uiConfig } = useUiConfig(); + const { unleashUrl } = uiConfig; + const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); + + const { feature, refetchFeature } = useFeature(projectId, featureId); + + const ref = useRef(feature); + + const { data, staleDataNotification, forceRefreshCache } = + useCollaborateData( + { + unleashGetter: useFeature, + params: [projectId, featureId], + dataKey: 'feature', + refetchFunctionKey: 'refetchFeature', + options: {}, + }, + feature, + { + afterSubmitAction: refetchFeature, + }, + comparisonModerator + ); + + useEffect(() => { + if (ref.current.name === '' && feature.name) { + forceRefreshCache(feature); + ref.current = feature; + } + }, [feature]); + + const payload = { + ...strategy, + segments: segments.map(segment => segment.id), + }; + + const onInternalSubmit = async () => { + try { + await editChange(projectId, changeRequestId, change.id, { + action: strategy.id ? 'updateStrategy' : 'addStrategy', + feature: featureId, + payload, + }); + onSubmit(); + setToastData({ + title: 'Change updated', + type: 'success', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + if (!strategyDefinition) { + return null; + } + + if (!data) return null; + + return ( + { + e.stopPropagation(); + }} + > + + formatUpdateStrategyApiCode( + projectId, + changeRequestId, + change.id, + payload, + unleashUrl + ) + } + > + + {staleDataNotification} + + + ); +}; + +export const formatUpdateStrategyApiCode = ( + projectId: string, + changeRequestId: number, + changeId: number, + strategy: Partial, + unleashUrl?: string +): string => { + if (!unleashUrl) { + return ''; + } + + const url = `${unleashUrl}/api/admin/projects/${projectId}/change-requests/${changeRequestId}/changes/${changeId}`; + const payload = JSON.stringify(strategy, undefined, 2); + + return `curl --location --request PUT '${url}' \\ + --header 'Authorization: INSERT_API_KEY' \\ + --header 'Content-Type: application/json' \\ + --data-raw '${payload}'`; +}; + +export const featureStrategyHelp = ` + An activation strategy will only run when a feature toggle is enabled and provides a way to control who will get access to the feature. + If any of a feature toggle's activation strategies returns true, the user will get access. +`; + +export const featureStrategyDocsLink = + 'https://docs.getunleash.io/reference/activation-strategies'; + +export const featureStrategyDocsLinkLabel = 'Strategies documentation'; diff --git a/frontend/src/component/changeRequest/ChangeRequestSidebar/EnvironmentChangeRequest/EnvironmentChangeRequestTitle.test.tsx b/frontend/src/component/changeRequest/ChangeRequestSidebar/EnvironmentChangeRequest/EnvironmentChangeRequestTitle.test.tsx index 4ae5bb78ae..8a64aa2973 100644 --- a/frontend/src/component/changeRequest/ChangeRequestSidebar/EnvironmentChangeRequest/EnvironmentChangeRequestTitle.test.tsx +++ b/frontend/src/component/changeRequest/ChangeRequestSidebar/EnvironmentChangeRequest/EnvironmentChangeRequestTitle.test.tsx @@ -3,8 +3,8 @@ import { screen } from '@testing-library/react'; import { ChangeRequestTitle } from './ChangeRequestTitle'; import { ChangeRequestState } from '../../changeRequest.types'; import userEvent from '@testing-library/user-event'; -import { testServerRoute, testServerSetup } from '../../../../utils/testServer'; -import { render } from '../../../../utils/testRenderer'; +import { testServerRoute, testServerSetup } from 'utils/testServer'; +import { render } from 'utils/testRenderer'; import { UIProviderContainer } from '../../../providers/UIProvider/UIProviderContainer'; const changeRequest = { diff --git a/frontend/src/component/changeRequest/ProjectChangeRequests/ChangeRequestsTabs/ChangeRequestsTabs.tsx b/frontend/src/component/changeRequest/ProjectChangeRequests/ChangeRequestsTabs/ChangeRequestsTabs.tsx index 43f94d2acb..c079a224f3 100644 --- a/frontend/src/component/changeRequest/ProjectChangeRequests/ChangeRequestsTabs/ChangeRequestsTabs.tsx +++ b/frontend/src/component/changeRequest/ProjectChangeRequests/ChangeRequestsTabs/ChangeRequestsTabs.tsx @@ -111,9 +111,18 @@ export const ChangeRequestsTabs = ({ Header: 'Updated feature toggles', canSort: false, accessor: 'features', - Cell: ({ value }: any) => { - return ; - }, + Cell: ({ + value, + row: { + original: { title }, + }, + }: any) => ( + + ), }, { Header: 'By', diff --git a/frontend/src/component/changeRequest/changeRequest.types.ts b/frontend/src/component/changeRequest/changeRequest.types.ts index a529906f6c..0592725028 100644 --- a/frontend/src/component/changeRequest/changeRequest.types.ts +++ b/frontend/src/component/changeRequest/changeRequest.types.ts @@ -106,7 +106,7 @@ type ChangeRequestEnabled = { enabled: boolean }; type ChangeRequestAddStrategy = Pick< IFeatureStrategy, - 'parameters' | 'constraints' + 'parameters' | 'constraints' | 'segments' > & { name: string }; type ChangeRequestEditStrategy = ChangeRequestAddStrategy & { id: string }; diff --git a/frontend/src/component/common/FormTemplate/FormTemplate.tsx b/frontend/src/component/common/FormTemplate/FormTemplate.tsx index 04ee0c370b..bd82aabe72 100644 --- a/frontend/src/component/common/FormTemplate/FormTemplate.tsx +++ b/frontend/src/component/common/FormTemplate/FormTemplate.tsx @@ -160,7 +160,6 @@ const FormTemplate: React.FC = ({ }) => { const { setToastData } = useToast(); const smallScreen = useMediaQuery(`(max-width:${1099}px)`); - const copyCommand = () => { if (copy(formatApiCode())) { setToastData({ diff --git a/frontend/src/component/common/SidebarModal/SidebarModal.tsx b/frontend/src/component/common/SidebarModal/SidebarModal.tsx index fd80d159f5..82b3dba1f1 100644 --- a/frontend/src/component/common/SidebarModal/SidebarModal.tsx +++ b/frontend/src/component/common/SidebarModal/SidebarModal.tsx @@ -9,6 +9,7 @@ interface ISidebarModalProps { open: boolean; onClose: () => void; label: string; + onClick?: (e: React.SyntheticEvent) => void; children: React.ReactElement; } @@ -39,6 +40,7 @@ const StyledIconButton = styled(IconButton)(({ theme }) => ({ export const BaseModal: FC = ({ open, onClose, + onClick, label, children, }) => { @@ -46,6 +48,7 @@ export const BaseModal: FC = ({ void; + onCancel?: () => void; loading: boolean; isChangeRequest?: boolean; strategy: Partial; @@ -74,6 +75,7 @@ export const FeatureStrategyForm = ({ environmentId, permission, onSubmit, + onCancel, loading, strategy, setStrategy, @@ -149,7 +151,7 @@ export const FeatureStrategyForm = ({ .every(Boolean); }; - const onCancel = () => { + const onDefaultCancel = () => { navigate(formatFeaturePath(feature.project, feature.name)); }; @@ -270,7 +272,7 @@ export const FeatureStrategyForm = ({