From 9fa7f5aa7b639563415ee28d8b1bb315de045ad0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Wed, 5 Feb 2025 15:27:36 +0000 Subject: [PATCH] chore: release plan changes in change request view (#9225) https://linear.app/unleash/issue/2-3169/add-release-plan-ui-representation-in-change-request-ui Adds visual representations for release plan change requests. ### Add release plan ![image](https://github.com/user-attachments/assets/8511c6a3-c83e-4eee-aa18-9affe4a9ac1d) ### Remove release plan ![image](https://github.com/user-attachments/assets/ed13f9ac-140c-40c9-a1a2-3c066c89c09a) ### Start milestone ![image](https://github.com/user-attachments/assets/ac8e5408-e877-470c-a98b-295b41444bfa) ![image](https://github.com/user-attachments/assets/abf19a55-89df-4dd8-8738-9dfcd63949b7) --- .../Changes/Change/FeatureChange.tsx | 13 + .../Changes/Change/ReleasePlanChange.tsx | 277 ++++++++++++++++++ .../changeRequest/changeRequest.types.ts | 46 ++- .../ReleasePlan/ReleasePlan.tsx | 39 ++- .../ReleasePlanMilestone.tsx | 30 +- 5 files changed, 376 insertions(+), 29 deletions(-) create mode 100644 frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ReleasePlanChange.tsx diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx index 256430088b..9f4f070652 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/FeatureChange.tsx @@ -14,6 +14,7 @@ import { EnvironmentStrategyExecutionOrder } from './EnvironmentStrategyExecutio import { ArchiveFeatureChange } from './ArchiveFeatureChange'; import { DependencyChange } from './DependencyChange'; import { Link } from 'react-router-dom'; +import { ReleasePlanChange } from './ReleasePlanChange'; const StyledSingleChangeBox = styled(Box, { shouldForwardProp: (prop: string) => !prop.startsWith('$'), @@ -192,6 +193,18 @@ export const FeatureChange: FC<{ actions={actions} /> )} + {(change.action === 'addReleasePlan' || + change.action === 'deleteReleasePlan' || + change.action === 'startMilestone') && ( + + )} ); diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ReleasePlanChange.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ReleasePlanChange.tsx new file mode 100644 index 0000000000..cdec617660 --- /dev/null +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ReleasePlanChange.tsx @@ -0,0 +1,277 @@ +import type React from 'react'; +import type { FC, ReactNode } from 'react'; +import { Box, styled, Typography } from '@mui/material'; +import type { + ChangeRequestState, + IChangeRequestAddReleasePlan, + IChangeRequestDeleteReleasePlan, + IChangeRequestStartMilestone, +} from 'component/changeRequest/changeRequest.types'; +import { useReleasePlanTemplate } from 'hooks/api/getters/useReleasePlanTemplates/useReleasePlanTemplate'; +import { useReleasePlans } from 'hooks/api/getters/useReleasePlans/useReleasePlans'; +import { TooltipLink } from 'component/common/TooltipLink/TooltipLink'; +import EventDiff from 'component/events/EventDiff/EventDiff'; +import { ReleasePlan } from 'component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan'; +import { ReleasePlanMilestone } from 'component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestone'; + +export const ChangeItemWrapper = styled(Box)({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', +}); + +const ChangeItemCreateEditDeleteWrapper = styled(Box)(({ theme }) => ({ + display: 'grid', + gridTemplateColumns: 'auto auto', + justifyContent: 'space-between', + gap: theme.spacing(1), + alignItems: 'center', + marginBottom: theme.spacing(2), + width: '100%', +})); + +const ChangeItemInfo: FC<{ children?: React.ReactNode }> = styled(Box)( + ({ theme }) => ({ + display: 'flex', + gap: theme.spacing(1), + }), +); + +const ViewDiff = styled('span')(({ theme }) => ({ + color: theme.palette.primary.main, + marginLeft: theme.spacing(1), +})); + +const StyledCodeSection = styled('div')(({ theme }) => ({ + overflowX: 'auto', + '& code': { + wordWrap: 'break-word', + whiteSpace: 'pre-wrap', + fontFamily: 'monospace', + lineHeight: 1.5, + fontSize: theme.fontSizes.smallBody, + }, +})); + +const DeleteReleasePlan: FC<{ + change: IChangeRequestDeleteReleasePlan; + environmentName: string; + featureName: string; + projectId: string; + changeRequestState: ChangeRequestState; + actions?: ReactNode; +}> = ({ + change, + environmentName, + featureName, + projectId, + changeRequestState, + actions, +}) => { + const { releasePlans } = useReleasePlans( + projectId, + featureName, + environmentName, + ); + const currentReleasePlan = releasePlans[0]; + + const releasePlan = + changeRequestState === 'Applied' && change.payload.snapshot + ? change.payload.snapshot + : currentReleasePlan; + + if (!releasePlan) return; + + return ( + <> + + + ({ + color: theme.palette.error.main, + })} + > + - Deleting release plan: + + {releasePlan.name} + +
{actions}
+
+ + + ); +}; + +const StartMilestone: FC<{ + change: IChangeRequestStartMilestone; + environmentName: string; + featureName: string; + projectId: string; + changeRequestState: ChangeRequestState; + actions?: ReactNode; +}> = ({ + change, + environmentName, + featureName, + projectId, + changeRequestState, + actions, +}) => { + const { releasePlans } = useReleasePlans( + projectId, + featureName, + environmentName, + ); + const currentReleasePlan = releasePlans[0]; + + const releasePlan = + changeRequestState === 'Applied' && change.payload.snapshot + ? change.payload.snapshot + : currentReleasePlan; + + if (!releasePlan) return; + + const previousMilestone = releasePlan.milestones.find( + (milestone) => milestone.id === releasePlan.activeMilestoneId, + ); + + const newMilestone = releasePlan.milestones.find( + (milestone) => milestone.id === change.payload.milestoneId, + ); + + if (!newMilestone) return; + + return ( + <> + + + + + Start milestone: + + {newMilestone.name} + + + + } + tooltipProps={{ + maxWidth: 500, + maxHeight: 600, + }} + > + View Diff + + +
{actions}
+
+ + + ); +}; + +const AddReleasePlan: FC<{ + change: IChangeRequestAddReleasePlan; + environmentName: string; + featureName: string; + actions?: ReactNode; +}> = ({ change, environmentName, featureName, actions }) => { + const { template } = useReleasePlanTemplate(change.payload.templateId); + + if (!template) return; + + const tentativeReleasePlan = { + ...template, + environment: environmentName, + featureName, + milestones: template.milestones.map((milestone) => ({ + ...milestone, + releasePlanDefinitionId: template.id, + strategies: (milestone.strategies || []).map((strategy) => ({ + ...strategy, + parameters: { + ...strategy.parameters, + ...(strategy.parameters.groupId && { + groupId: String(strategy.parameters.groupId).replaceAll( + '{{featureName}}', + featureName, + ), + }), + }, + milestoneId: milestone.id, + })), + })), + }; + + return ( + <> + + + + + Adding release plan: + + {template.name} + +
{actions}
+
+ + + ); +}; + +export const ReleasePlanChange: FC<{ + actions?: ReactNode; + change: + | IChangeRequestAddReleasePlan + | IChangeRequestDeleteReleasePlan + | IChangeRequestStartMilestone; + environmentName: string; + featureName: string; + projectId: string; + changeRequestState: ChangeRequestState; +}> = ({ + actions, + change, + featureName, + environmentName, + projectId, + changeRequestState, +}) => { + return ( + <> + {change.action === 'addReleasePlan' && ( + + )} + {change.action === 'deleteReleasePlan' && ( + + )} + {change.action === 'startMilestone' && ( + + )} + + ); +}; diff --git a/frontend/src/component/changeRequest/changeRequest.types.ts b/frontend/src/component/changeRequest/changeRequest.types.ts index e8168eeda1..3622cfa65f 100644 --- a/frontend/src/component/changeRequest/changeRequest.types.ts +++ b/frontend/src/component/changeRequest/changeRequest.types.ts @@ -3,6 +3,7 @@ import type { ISegment } from 'interfaces/segment'; import type { IFeatureStrategy } from '../../interfaces/strategy'; import type { IUser } from '../../interfaces/user'; import type { SetStrategySortOrderSchema } from '../../openapi'; +import type { IReleasePlan } from 'interfaces/releasePlans'; type BaseChangeRequest = { id: number; @@ -126,7 +127,10 @@ type ChangeRequestPayload = | IChangeRequestDeleteSegment | SetStrategySortOrderSchema | IChangeRequestArchiveFeature - | ChangeRequestAddDependency; + | ChangeRequestAddDependency + | ChangeRequestAddReleasePlan + | ChangeRequestDeleteReleasePlan + | ChangeRequestStartMilestone; export interface IChangeRequestAddStrategy extends IChangeRequestChangeBase { action: 'addStrategy'; @@ -167,6 +171,22 @@ export interface IChangeRequestDeleteDependency action: 'deleteDependency'; } +export interface IChangeRequestAddReleasePlan extends IChangeRequestChangeBase { + action: 'addReleasePlan'; + payload: ChangeRequestAddReleasePlan; +} + +export interface IChangeRequestDeleteReleasePlan + extends IChangeRequestChangeBase { + action: 'deleteReleasePlan'; + payload: ChangeRequestDeleteReleasePlan; +} + +export interface IChangeRequestStartMilestone extends IChangeRequestChangeBase { + action: 'startMilestone'; + payload: ChangeRequestStartMilestone; +} + export interface IChangeRequestReorderStrategy extends IChangeRequestChangeBase { action: 'reorderStrategy'; @@ -211,7 +231,10 @@ export type IFeatureChange = | IChangeRequestReorderStrategy | IChangeRequestArchiveFeature | IChangeRequestAddDependency - | IChangeRequestDeleteDependency; + | IChangeRequestDeleteDependency + | IChangeRequestAddReleasePlan + | IChangeRequestDeleteReleasePlan + | IChangeRequestStartMilestone; export type ISegmentChange = | IChangeRequestUpdateSegment @@ -230,6 +253,20 @@ type ChangeRequestAddDependency = { variants?: string[]; }; +type ChangeRequestAddReleasePlan = { + templateId: string; +}; + +type ChangeRequestDeleteReleasePlan = { + planId: string; + snapshot?: IReleasePlan; +}; + +type ChangeRequestStartMilestone = { + milestoneId: string; + snapshot?: IReleasePlan; +}; + export type ChangeRequestAddStrategy = Pick< IFeatureStrategy, | 'parameters' @@ -264,4 +301,7 @@ export type ChangeRequestAction = | 'deleteSegment' | 'archiveFeature' | 'addDependency' - | 'deleteDependency'; + | 'deleteDependency' + | 'addReleasePlan' + | 'deleteReleasePlan' + | 'startMilestone'; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx index ecb6a50c5b..241243cba0 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx @@ -16,13 +16,17 @@ import { ReleasePlanRemoveDialog } from './ReleasePlanRemoveDialog'; import { ReleasePlanMilestone } from './ReleasePlanMilestone/ReleasePlanMilestone'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; -const StyledContainer = styled('div')(({ theme }) => ({ +const StyledContainer = styled('div', { + shouldForwardProp: (prop) => prop !== 'readonly', +})<{ readonly?: boolean }>(({ theme, readonly }) => ({ padding: theme.spacing(2), borderRadius: theme.shape.borderRadiusMedium, '& + &': { marginTop: theme.spacing(2), }, - background: theme.palette.background.paper, + background: readonly + ? theme.palette.background.elevation1 + : theme.palette.background.paper, })); const StyledHeader = styled('div')(({ theme }) => ({ @@ -66,12 +70,14 @@ const StyledConnection = styled('div')(({ theme }) => ({ interface IReleasePlanProps { plan: IReleasePlan; - environmentIsDisabled: boolean; + environmentIsDisabled?: boolean; + readonly?: boolean; } export const ReleasePlan = ({ plan, environmentIsDisabled, + readonly, }: IReleasePlanProps) => { const { id, @@ -134,7 +140,7 @@ export const ReleasePlan = ({ ); return ( - + @@ -145,22 +151,25 @@ export const ReleasePlan = ({ {description} - setRemoveOpen(true)} - permission={DELETE_FEATURE_STRATEGY} - environmentId={environment} - projectId={projectId} - tooltipProps={{ - title: 'Remove release plan', - }} - > - - + {!readonly && ( + setRemoveOpen(true)} + permission={DELETE_FEATURE_STRATEGY} + environmentId={environment} + projectId={projectId} + tooltipProps={{ + title: 'Remove release plan', + }} + > + + + )} {milestones.map((milestone, index) => (
({ interface IReleasePlanMilestoneProps { milestone: IReleasePlanMilestone; - status: MilestoneStatus; - onStartMilestone: (milestone: IReleasePlanMilestone) => void; + status?: MilestoneStatus; + onStartMilestone?: (milestone: IReleasePlanMilestone) => void; + readonly?: boolean; } export const ReleasePlanMilestone = ({ milestone, - status, + status = 'not-started', onStartMilestone, + readonly, }: IReleasePlanMilestoneProps) => { const [expanded, setExpanded] = useState(false); @@ -75,10 +77,14 @@ export const ReleasePlanMilestone = ({ {milestone.name} - onStartMilestone(milestone)} - /> + {!readonly && onStartMilestone && ( + + onStartMilestone(milestone) + } + /> + )} No strategies @@ -94,10 +100,12 @@ export const ReleasePlanMilestone = ({ }> {milestone.name} - onStartMilestone(milestone)} - /> + {!readonly && onStartMilestone && ( + onStartMilestone(milestone)} + /> + )} {milestone.strategies.length === 1