1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-08 01:15:49 +02:00

chore: new add release plan dialog (#9389)

https://linear.app/unleash/issue/2-3249/adding-a-release-plan-to-a-non-cr-environment-feels-too-immediate

This adds a new confirmation / preview dialog when adding a release
plan.

What's cool about it is that it will describe what will happen before
you confirm. It also acts as the "add to CR" dialog, so we now only have
1 dialog instead of 2 separate ones.

This also refactors quite a bit of our code here, hopefully simplifying
it.

### Simple (env disabled)

![image](https://github.com/user-attachments/assets/579697a8-5b21-4400-a48b-96d2df3931f6)

### CR protected (env enabled)

![image](https://github.com/user-attachments/assets/35398bc9-faed-4ce1-8c78-52e89fe21f4a)
This commit is contained in:
Nuno Góis 2025-02-28 10:57:20 +00:00 committed by GitHub
parent 2064cae20f
commit da91ae6afe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 226 additions and 185 deletions

View File

@ -7,7 +7,7 @@ import type {
IChangeRequestDeleteReleasePlan, IChangeRequestDeleteReleasePlan,
IChangeRequestStartMilestone, IChangeRequestStartMilestone,
} from 'component/changeRequest/changeRequest.types'; } from 'component/changeRequest/changeRequest.types';
import { useReleasePlanTemplate } from 'hooks/api/getters/useReleasePlanTemplates/useReleasePlanTemplate'; import { useReleasePlanPreview } from 'hooks/useReleasePlanPreview';
import { useReleasePlans } from 'hooks/api/getters/useReleasePlans/useReleasePlans'; import { useReleasePlans } from 'hooks/api/getters/useReleasePlans/useReleasePlans';
import { TooltipLink } from 'component/common/TooltipLink/TooltipLink'; import { TooltipLink } from 'component/common/TooltipLink/TooltipLink';
import EventDiff from 'component/events/EventDiff/EventDiff'; import EventDiff from 'component/events/EventDiff/EventDiff';
@ -181,32 +181,11 @@ const AddReleasePlan: FC<{
featureName: string; featureName: string;
actions?: ReactNode; actions?: ReactNode;
}> = ({ change, environmentName, featureName, actions }) => { }> = ({ change, environmentName, featureName, actions }) => {
const { template } = useReleasePlanTemplate(change.payload.templateId); const planPreview = useReleasePlanPreview(
change.payload.templateId,
if (!template) return;
const tentativeReleasePlan = {
...template,
environment: environmentName,
featureName, featureName,
milestones: template.milestones.map((milestone) => ({ environmentName,
...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 ( return (
<> <>
@ -215,11 +194,11 @@ const AddReleasePlan: FC<{
<Typography color='success.dark'> <Typography color='success.dark'>
+ Adding release plan: + Adding release plan:
</Typography> </Typography>
<Typography>{template.name}</Typography> <Typography>{planPreview.name}</Typography>
</ChangeItemInfo> </ChangeItemInfo>
<div>{actions}</div> <div>{actions}</div>
</ChangeItemCreateEditDeleteWrapper> </ChangeItemCreateEditDeleteWrapper>
<ReleasePlan plan={tentativeReleasePlan} readonly /> <ReleasePlan plan={planPreview} readonly />
</> </>
); );
}; };

View File

@ -1,14 +1,7 @@
import { getFeatureStrategyIcon } from 'utils/strategyNames'; import { getFeatureStrategyIcon } from 'utils/strategyNames';
import StringTruncator from 'component/common/StringTruncator/StringTruncator'; import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { Link, styled } from '@mui/material'; import { Button, styled } from '@mui/material';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import type { IReleasePlanTemplate } from 'interfaces/releasePlans'; import type { IReleasePlanTemplate } from 'interfaces/releasePlans';
import { useReleasePlansApi } from 'hooks/api/actions/useReleasePlansApi/useReleasePlansApi';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useReleasePlans } from 'hooks/api/getters/useReleasePlans/useReleasePlans';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { useUiFlag } from 'hooks/useUiFlag';
const StyledIcon = styled('div')(({ theme }) => ({ const StyledIcon = styled('div')(({ theme }) => ({
width: theme.spacing(4), width: theme.spacing(4),
@ -25,13 +18,14 @@ const StyledIcon = styled('div')(({ theme }) => ({
const StyledDescription = styled('div')(({ theme }) => ({ const StyledDescription = styled('div')(({ theme }) => ({
fontSize: theme.fontSizes.smallBody, fontSize: theme.fontSizes.smallBody,
fontWeight: theme.fontWeight.medium,
})); }));
const StyledName = styled(StringTruncator)(({ theme }) => ({ const StyledName = styled(StringTruncator)(({ theme }) => ({
fontWeight: theme.fontWeight.bold, fontWeight: theme.fontWeight.bold,
})); }));
const StyledCard = styled(Link)(({ theme }) => ({ const StyledCard = styled(Button)(({ theme }) => ({
display: 'grid', display: 'grid',
gridTemplateColumns: '3rem 1fr', gridTemplateColumns: '3rem 1fr',
width: '20rem', width: '20rem',
@ -43,82 +37,31 @@ const StyledCard = styled(Link)(({ theme }) => ({
borderStyle: 'solid', borderStyle: 'solid',
borderColor: theme.palette.divider, borderColor: theme.palette.divider,
borderRadius: theme.spacing(1), borderRadius: theme.spacing(1),
textAlign: 'left',
'&:hover, &:focus': { '&:hover, &:focus': {
borderColor: theme.palette.primary.main, borderColor: theme.palette.primary.main,
}, },
})); }));
interface IFeatureReleasePlanCardProps { interface IFeatureReleasePlanCardProps {
projectId: string; template: IReleasePlanTemplate;
featureId: string; onClick: () => void;
environmentId: string;
releasePlanTemplate: IReleasePlanTemplate;
setTemplateForChangeRequestDialog: (template: IReleasePlanTemplate) => void;
} }
export const FeatureReleasePlanCard = ({ export const FeatureReleasePlanCard = ({
projectId, template: { name, description },
featureId, onClick,
environmentId,
releasePlanTemplate,
setTemplateForChangeRequestDialog,
}: IFeatureReleasePlanCardProps) => { }: IFeatureReleasePlanCardProps) => {
const Icon = getFeatureStrategyIcon('releasePlanTemplate'); const Icon = getFeatureStrategyIcon('releasePlanTemplate');
const { trackEvent } = usePlausibleTracker();
const { refetch } = useReleasePlans(projectId, featureId, environmentId);
const { addReleasePlanToFeature } = useReleasePlansApi();
const { setToastApiError, setToastData } = useToast();
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const releasePlanChangeRequestsEnabled = useUiFlag(
'releasePlanChangeRequests',
);
const addReleasePlan = async () => {
try {
if (
releasePlanChangeRequestsEnabled &&
isChangeRequestConfigured(environmentId)
) {
setTemplateForChangeRequestDialog(releasePlanTemplate);
} else {
await addReleasePlanToFeature(
featureId,
releasePlanTemplate.id,
projectId,
environmentId,
);
setToastData({
type: 'success',
text: 'Release plan added',
});
refetch();
}
trackEvent('release-management', {
props: {
eventType: 'add-plan',
plan: releasePlanTemplate.name,
},
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
return ( return (
<StyledCard onClick={addReleasePlan}> <StyledCard onClick={onClick}>
<StyledIcon> <StyledIcon>
<Icon /> <Icon />
</StyledIcon> </StyledIcon>
<div> <div>
<StyledName <StyledName text={name} maxWidth='200' maxLength={25} />
text={releasePlanTemplate.name} <StyledDescription>{description}</StyledDescription>
maxWidth='200'
maxLength={25}
/>
<StyledDescription>
{releasePlanTemplate.description}
</StyledDescription>
</div> </div>
</StyledCard> </StyledCard>
); );

View File

@ -10,11 +10,16 @@ import { FeatureStrategyMenuCards } from './FeatureStrategyMenuCards/FeatureStra
import { formatCreateStrategyPath } from '../FeatureStrategyCreate/FeatureStrategyCreate'; import { formatCreateStrategyPath } from '../FeatureStrategyCreate/FeatureStrategyCreate';
import MoreVert from '@mui/icons-material/MoreVert'; import MoreVert from '@mui/icons-material/MoreVert';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { ReleasePlanAddChangeRequestDialog } from 'component/feature/FeatureView/FeatureOverview/ReleasePlan/ChangeRequest/ReleasePlanAddChangeRequestDialog';
import type { IReleasePlanTemplate } from 'interfaces/releasePlans'; import type { IReleasePlanTemplate } from 'interfaces/releasePlans';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi'; import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests'; import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
import { ReleasePlanAddDialog } from 'component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanAddDialog';
import { useReleasePlansApi } from 'hooks/api/actions/useReleasePlansApi/useReleasePlansApi';
import { useReleasePlans } from 'hooks/api/getters/useReleasePlans/useReleasePlans';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useUiFlag } from 'hooks/useUiFlag';
interface IFeatureStrategyMenuProps { interface IFeatureStrategyMenuProps {
label: string; label: string;
@ -54,14 +59,25 @@ export const FeatureStrategyMenu = ({
const [anchor, setAnchor] = useState<Element>(); const [anchor, setAnchor] = useState<Element>();
const navigate = useNavigate(); const navigate = useNavigate();
const { trackEvent } = usePlausibleTracker(); const { trackEvent } = usePlausibleTracker();
const [templateForChangeRequestDialog, setTemplateForChangeRequestDialog] = const [selectedTemplate, setSelectedTemplate] =
useState<IReleasePlanTemplate | undefined>(); useState<IReleasePlanTemplate>();
const [addReleasePlanOpen, setAddReleasePlanOpen] = useState(false);
const isPopoverOpen = Boolean(anchor); const isPopoverOpen = Boolean(anchor);
const popoverId = isPopoverOpen ? 'FeatureStrategyMenuPopover' : undefined; const popoverId = isPopoverOpen ? 'FeatureStrategyMenuPopover' : undefined;
const { setToastData } = useToast(); const { setToastApiError, setToastData } = useToast();
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const { addChange } = useChangeRequestApi(); const { addChange } = useChangeRequestApi();
const { refetch: refetchChangeRequests } = const { refetch: refetchChangeRequests } =
usePendingChangeRequests(projectId); usePendingChangeRequests(projectId);
const { refetch } = useReleasePlans(projectId, featureId, environmentId);
const { addReleasePlanToFeature } = useReleasePlansApi();
const releasePlanChangeRequestsEnabled = useUiFlag(
'releasePlanChangeRequests',
);
const crProtected =
releasePlanChangeRequestsEnabled &&
isChangeRequestConfigured(environmentId);
const onClose = () => { const onClose = () => {
setAnchor(undefined); setAnchor(undefined);
@ -80,23 +96,52 @@ export const FeatureStrategyMenu = ({
setAnchor(event.currentTarget); setAnchor(event.currentTarget);
}; };
const addReleasePlanToChangeRequest = async () => { const addReleasePlan = async () => {
if (!selectedTemplate) return;
try {
if (crProtected) {
await addChange(projectId, environmentId, { await addChange(projectId, environmentId, {
feature: featureId, feature: featureId,
action: 'addReleasePlan', action: 'addReleasePlan',
payload: { payload: {
templateId: templateForChangeRequestDialog?.id, templateId: selectedTemplate.id,
}, },
}); });
await refetchChangeRequests();
setToastData({ setToastData({
type: 'success', type: 'success',
text: 'Added to draft', text: 'Added to draft',
}); });
setTemplateForChangeRequestDialog(undefined); refetchChangeRequests();
} else {
await addReleasePlanToFeature(
featureId,
selectedTemplate.id,
projectId,
environmentId,
);
setToastData({
type: 'success',
text: 'Release plan added',
});
refetch();
}
trackEvent('release-management', {
props: {
eventType: 'add-plan',
plan: selectedTemplate.name,
},
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
} finally {
setAddReleasePlanOpen(false);
setSelectedTemplate(undefined);
}
}; };
const createStrategyPath = formatCreateStrategyPath( const createStrategyPath = formatCreateStrategyPath(
@ -156,19 +201,24 @@ export const FeatureStrategyMenu = ({
projectId={projectId} projectId={projectId}
featureId={featureId} featureId={featureId}
environmentId={environmentId} environmentId={environmentId}
setTemplateForChangeRequestDialog={ onAddReleasePlan={(template) => {
setTemplateForChangeRequestDialog setSelectedTemplate(template);
} setAddReleasePlanOpen(true);
}}
/> />
</Popover> </Popover>
<ReleasePlanAddChangeRequestDialog {selectedTemplate && (
onConfirm={addReleasePlanToChangeRequest} <ReleasePlanAddDialog
onClosing={() => setTemplateForChangeRequestDialog(undefined)} open={addReleasePlanOpen}
isOpen={Boolean(templateForChangeRequestDialog)} setOpen={setAddReleasePlanOpen}
featureId={featureId} onConfirm={addReleasePlan}
environmentId={environmentId} template={selectedTemplate}
releaseTemplate={templateForChangeRequestDialog} projectId={projectId}
featureName={featureId}
environment={environmentId}
crProtected={crProtected}
/> />
)}
</StyledStrategyMenu> </StyledStrategyMenu>
); );
}; };

View File

@ -10,7 +10,7 @@ interface IFeatureStrategyMenuCardsProps {
projectId: string; projectId: string;
featureId: string; featureId: string;
environmentId: string; environmentId: string;
setTemplateForChangeRequestDialog: (template: IReleasePlanTemplate) => void; onAddReleasePlan: (template: IReleasePlanTemplate) => void;
} }
const StyledTypography = styled(Typography)(({ theme }) => ({ const StyledTypography = styled(Typography)(({ theme }) => ({
@ -22,7 +22,7 @@ export const FeatureStrategyMenuCards = ({
projectId, projectId,
featureId, featureId,
environmentId, environmentId,
setTemplateForChangeRequestDialog, onAddReleasePlan,
}: IFeatureStrategyMenuCardsProps) => { }: IFeatureStrategyMenuCardsProps) => {
const { strategies } = useStrategies(); const { strategies } = useStrategies();
const { templates } = useReleasePlanTemplates(); const { templates } = useReleasePlanTemplates();
@ -67,13 +67,8 @@ export const FeatureStrategyMenuCards = ({
{templates.map((template) => ( {templates.map((template) => (
<ListItem key={template.id}> <ListItem key={template.id}>
<FeatureReleasePlanCard <FeatureReleasePlanCard
projectId={projectId} template={template}
featureId={featureId} onClick={() => onAddReleasePlan(template)}
environmentId={environmentId}
releasePlanTemplate={template}
setTemplateForChangeRequestDialog={
setTemplateForChangeRequestDialog
}
/> />
</ListItem> </ListItem>
))} ))}

View File

@ -1,51 +0,0 @@
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { styled, Button } from '@mui/material';
import type { IReleasePlanTemplate } from 'interfaces/releasePlans';
const StyledBoldSpan = styled('span')(({ theme }) => ({
fontWeight: theme.typography.fontWeightBold,
}));
interface IReleasePlanAddChangeRequestDialogProps {
featureId: string;
environmentId: string;
releaseTemplate?: IReleasePlanTemplate;
isOpen: boolean;
onConfirm: () => Promise<void>;
onClosing: () => void;
}
export const ReleasePlanAddChangeRequestDialog = ({
featureId,
environmentId,
releaseTemplate,
isOpen,
onConfirm,
onClosing,
}: IReleasePlanAddChangeRequestDialogProps) => {
return (
<Dialogue
title='Request changes'
open={isOpen}
secondaryButtonText='Cancel'
onClose={onClosing}
customButton={
<Button
color='primary'
variant='contained'
onClick={onConfirm}
autoFocus={true}
>
Add suggestion to draft
</Button>
}
>
<p>
<StyledBoldSpan>Add</StyledBoldSpan> release template{' '}
<StyledBoldSpan>{releaseTemplate?.name}</StyledBoldSpan> to{' '}
<StyledBoldSpan>{featureId}</StyledBoldSpan> in{' '}
<StyledBoldSpan>{environmentId}</StyledBoldSpan>
</p>
</Dialogue>
);
};

View File

@ -0,0 +1,92 @@
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import type { IReleasePlanTemplate } from 'interfaces/releasePlans';
import { ReleasePlan } from './ReleasePlan';
import { useReleasePlanPreview } from 'hooks/useReleasePlanPreview';
import { styled, Typography, Alert } from '@mui/material';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
const StyledReleasePlanContainer = styled('div')(({ theme }) => ({
margin: theme.spacing(2, 0),
}));
interface IReleasePlanAddDialogProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
onConfirm: () => void;
template: IReleasePlanTemplate;
projectId: string;
featureName: string;
environment: string;
crProtected?: boolean;
}
export const ReleasePlanAddDialog = ({
open,
setOpen,
onConfirm,
template,
projectId,
featureName,
environment,
crProtected,
}: IReleasePlanAddDialogProps) => {
const { feature } = useFeature(projectId, featureName);
const environmentData = feature?.environments.find(
({ name }) => name === environment,
);
const environmentEnabled = environmentData?.enabled;
const planPreview = useReleasePlanPreview(
template.id,
featureName,
environment,
);
const firstMilestone = planPreview.milestones[0];
return (
<Dialogue
title='Add release plan?'
open={open}
primaryButtonText={
crProtected ? 'Add suggestion to draft' : 'Add release plan'
}
secondaryButtonText='Cancel'
onClick={onConfirm}
onClose={() => setOpen(false)}
>
{environmentEnabled ? (
<Alert severity='info'>
This environment is currently <strong>enabled</strong>.
{firstMilestone && (
<p>
The first milestone will be started as soon as the
release plan is added:{' '}
<strong>{planPreview.milestones[0].name}</strong>
</p>
)}
</Alert>
) : (
<Alert severity='warning'>
This environment is currently <strong>disabled</strong>.
<p>
The milestones will not start automatically after adding
the release plan. They will remain paused until the
environment is enabled.
</p>
</Alert>
)}
<StyledReleasePlanContainer>
<ReleasePlan plan={planPreview} readonly />
</StyledReleasePlanContainer>
{crProtected && (
<Typography sx={{ mt: 4 }}>
<strong>Adding</strong> release plan template{' '}
<strong>{template?.name}</strong> to{' '}
<strong>{featureName}</strong> in{' '}
<strong>{environment}</strong>.
</Typography>
)}
</Dialogue>
);
};

View File

@ -0,0 +1,33 @@
import type { IReleasePlan } from 'interfaces/releasePlans';
import { useReleasePlanTemplate } from './api/getters/useReleasePlanTemplates/useReleasePlanTemplate';
export const useReleasePlanPreview = (
templateId: string,
featureName: string,
environment: string,
): IReleasePlan => {
const { template } = useReleasePlanTemplate(templateId);
return {
...template,
featureName,
environment,
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,
})),
})),
};
};