1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-03 01:18:43 +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,
IChangeRequestStartMilestone,
} 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 { TooltipLink } from 'component/common/TooltipLink/TooltipLink';
import EventDiff from 'component/events/EventDiff/EventDiff';
@ -181,32 +181,11 @@ const AddReleasePlan: FC<{
featureName: string;
actions?: ReactNode;
}> = ({ change, environmentName, featureName, actions }) => {
const { template } = useReleasePlanTemplate(change.payload.templateId);
if (!template) return;
const tentativeReleasePlan = {
...template,
environment: environmentName,
const planPreview = useReleasePlanPreview(
change.payload.templateId,
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,
})),
})),
};
environmentName,
);
return (
<>
@ -215,11 +194,11 @@ const AddReleasePlan: FC<{
<Typography color='success.dark'>
+ Adding release plan:
</Typography>
<Typography>{template.name}</Typography>
<Typography>{planPreview.name}</Typography>
</ChangeItemInfo>
<div>{actions}</div>
</ChangeItemCreateEditDeleteWrapper>
<ReleasePlan plan={tentativeReleasePlan} readonly />
<ReleasePlan plan={planPreview} readonly />
</>
);
};

View File

@ -1,14 +1,7 @@
import { getFeatureStrategyIcon } from 'utils/strategyNames';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { Link, styled } from '@mui/material';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { Button, styled } from '@mui/material';
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 }) => ({
width: theme.spacing(4),
@ -25,13 +18,14 @@ const StyledIcon = styled('div')(({ theme }) => ({
const StyledDescription = styled('div')(({ theme }) => ({
fontSize: theme.fontSizes.smallBody,
fontWeight: theme.fontWeight.medium,
}));
const StyledName = styled(StringTruncator)(({ theme }) => ({
fontWeight: theme.fontWeight.bold,
}));
const StyledCard = styled(Link)(({ theme }) => ({
const StyledCard = styled(Button)(({ theme }) => ({
display: 'grid',
gridTemplateColumns: '3rem 1fr',
width: '20rem',
@ -43,82 +37,31 @@ const StyledCard = styled(Link)(({ theme }) => ({
borderStyle: 'solid',
borderColor: theme.palette.divider,
borderRadius: theme.spacing(1),
textAlign: 'left',
'&:hover, &:focus': {
borderColor: theme.palette.primary.main,
},
}));
interface IFeatureReleasePlanCardProps {
projectId: string;
featureId: string;
environmentId: string;
releasePlanTemplate: IReleasePlanTemplate;
setTemplateForChangeRequestDialog: (template: IReleasePlanTemplate) => void;
template: IReleasePlanTemplate;
onClick: () => void;
}
export const FeatureReleasePlanCard = ({
projectId,
featureId,
environmentId,
releasePlanTemplate,
setTemplateForChangeRequestDialog,
template: { name, description },
onClick,
}: IFeatureReleasePlanCardProps) => {
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 (
<StyledCard onClick={addReleasePlan}>
<StyledCard onClick={onClick}>
<StyledIcon>
<Icon />
</StyledIcon>
<div>
<StyledName
text={releasePlanTemplate.name}
maxWidth='200'
maxLength={25}
/>
<StyledDescription>
{releasePlanTemplate.description}
</StyledDescription>
<StyledName text={name} maxWidth='200' maxLength={25} />
<StyledDescription>{description}</StyledDescription>
</div>
</StyledCard>
);

View File

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

View File

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