From 14403d78367c3c5034f37932022858d975a93104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Tue, 26 Nov 2024 09:15:24 +0000 Subject: [PATCH] chore: release plans flow in flag environments (#8843) https://linear.app/unleash/issue/2-2816/add-release-plan-to-feature-flag-from-release-template https://linear.app/unleash/issue/2-2818/list-release-plan-with-milestones-in-feature-flag-environment-section https://linear.app/unleash/issue/2-2819/removing-release-plan-from-feature Implements the release plan flow in the feature flag environment. You can now manage release plans in a feature flag environment by adding or removing them, as well as start milestones. https://github.com/user-attachments/assets/24db9db4-7c3a-463e-b48a-611358f2b212 --- .../FeatureReleasePlanCard.tsx | 3 + .../EnvironmentAccordionBody.tsx | 175 ++++++++++------ .../EnvironmentFooter/EnvironmentFooter.tsx | 10 +- .../SectionSeparator/SectionSeparator.tsx | 1 - .../FeatureOverviewEnvironmentBody.tsx | 166 +++++++++------ .../ReleasePlan/ReleasePlan.tsx | 193 ++++++++++++++++++ .../ReleasePlan/ReleasePlanMilestone.tsx | 146 +++++++++++++ .../ReleasePlanMilestoneStrategy.tsx | 55 +++++ .../ReleasePlan/ReleasePlanRemoveDialog.tsx | 43 ++++ .../ReleasePlanTemplate/MilestoneCard.tsx | 4 +- .../ReleasePlanTemplate/MilestoneList.tsx | 2 +- .../MilestoneStrategyMenuCard.tsx | 4 +- .../MilestoneStrategyMenuCards.tsx | 6 +- .../ReleasePlanTemplateAddStrategyForm.tsx | 6 +- .../ReleasePlanTemplate/TemplateForm.tsx | 8 +- .../useReleasePlansApi/useReleasePlansApi.ts | 29 +++ .../useReleasePlans/useReleasePlans.ts | 43 ++++ frontend/src/interfaces/releasePlans.ts | 23 ++- frontend/src/utils/strategyNames.tsx | 2 +- 19 files changed, 771 insertions(+), 148 deletions(-) create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneStrategy.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanRemoveDialog.tsx create mode 100644 frontend/src/hooks/api/getters/useReleasePlans/useReleasePlans.ts diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureReleasePlanCard/FeatureReleasePlanCard.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureReleasePlanCard/FeatureReleasePlanCard.tsx index 1669554380..a635cb570c 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureReleasePlanCard/FeatureReleasePlanCard.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureReleasePlanCard/FeatureReleasePlanCard.tsx @@ -6,6 +6,7 @@ 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'; const StyledIcon = styled('div')(({ theme }) => ({ width: theme.spacing(4), @@ -60,6 +61,7 @@ export const FeatureReleasePlanCard = ({ }: IFeatureReleasePlanCardProps) => { const Icon = getFeatureStrategyIcon('releasePlanTemplate'); const { trackEvent } = usePlausibleTracker(); + const { refetch } = useReleasePlans(projectId, featureId, environmentId); const { addReleasePlanToFeature } = useReleasePlansApi(); const { setToastApiError, setToastData } = useToast(); @@ -75,6 +77,7 @@ export const FeatureReleasePlanCard = ({ type: 'success', title: 'Release plan added', }); + refetch(); } catch (error: unknown) { setToastApiError(formatUnknownError(error)); } diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/EnvironmentAccordionBody.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/EnvironmentAccordionBody.tsx index ed3674c665..4c3a94ce86 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/EnvironmentAccordionBody.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/EnvironmentAccordionBody.tsx @@ -21,6 +21,10 @@ import usePagination from 'hooks/usePagination'; import type { IFeatureStrategy } from 'interfaces/strategy'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; import { useUiFlag } from 'hooks/useUiFlag'; +import { useReleasePlans } from 'hooks/api/getters/useReleasePlans/useReleasePlans'; +import { ReleasePlan } from '../../../ReleasePlan/ReleasePlan'; +import { Badge } from 'component/common/Badge/Badge'; +import { SectionSeparator } from '../SectionSeparator/SectionSeparator'; interface IEnvironmentAccordionBodyProps { isDisabled: boolean; @@ -40,6 +44,14 @@ const StyledAccordionBodyInnerContainer = styled('div')(({ theme }) => ({ }, })); +const StyledBadge = styled(Badge)(({ theme }) => ({ + backgroundColor: theme.palette.primary.light, + border: 'none', + padding: theme.spacing(0.75, 1.5), + borderRadius: theme.shape.borderRadiusLarge, + color: theme.palette.common.white, +})); + const EnvironmentAccordionBody = ({ featureEnvironment, isDisabled, @@ -58,6 +70,11 @@ const EnvironmentAccordionBody = ({ const [strategies, setStrategies] = useState( featureEnvironment?.strategies || [], ); + const { releasePlans } = useReleasePlans( + projectId, + featureId, + featureEnvironment?.name, + ); const { trackEvent } = usePlausibleTracker(); const [dragItem, setDragItem] = useState<{ @@ -201,7 +218,10 @@ const EnvironmentAccordionBody = ({ 0 && isDisabled} + condition={ + (releasePlans.length > 0 || strategies.length > 0) && + isDisabled + } show={() => ( This environment is disabled, which means that none @@ -210,74 +230,97 @@ const EnvironmentAccordionBody = ({ )} /> 0} + condition={releasePlans.length > 0 || strategies.length > 0} show={ - - {strategies.map((strategy, index) => ( - + {releasePlans.map((plan) => ( + + ))} + 0 && + strategies.length > 0 + } + show={ + + OR + + } + /> + + {strategies.map((strategy, index) => ( + + ))} + + } + elseShow={ + <> + + We noticed you're using a high + number of activation strategies. To + ensure a more targeted approach, + consider leveraging constraints or + segments. + +
+ {page.map((strategy, index) => ( + {}) as any + } + onDragOver={(() => {}) as any} + onDragEnd={(() => {}) as any} + /> + ))} +
+ + setPageIndex(page - 1) } - otherEnvironments={ - otherEnvironments - } - isDragging={ - dragItem?.id === strategy.id - } - onDragStartRef={onDragStartRef} - onDragOver={onDragOver(strategy.id)} - onDragEnd={onDragEnd} /> - ))} - - } - elseShow={ - <> - - We noticed you're using a high number of - activation strategies. To ensure a more - targeted approach, consider leveraging - constraints or segments. - -
- {page.map((strategy, index) => ( - {}) as any} - onDragOver={(() => {}) as any} - onDragEnd={(() => {}) as any} - /> - ))} -
- - setPageIndex(page - 1) - } - /> - - } - /> + + } + /> + } elseShow={ ({ + background: theme.palette.envAccordion.expanded, + padding: theme.spacing(0, 2), +})); interface IEnvironmentFooterProps { environmentMetric?: IFeatureEnvironmentMetrics; @@ -15,7 +21,9 @@ export const EnvironmentFooter = ({ return ( <> - Feature flag exposure + + Feature flag exposure +
({ fontSize: theme.fontSizes.bodySize, textAlign: 'center', padding: '0 1rem', - background: theme.palette.envAccordion.expanded, position: 'relative', maxWidth: '80%', color: theme.palette.text.primary, diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/FeatureOverviewEnvironmentBody.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/FeatureOverviewEnvironmentBody.tsx index 5cea19c555..664046dee3 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/FeatureOverviewEnvironmentBody.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/FeatureOverviewEnvironmentBody.tsx @@ -22,6 +22,10 @@ import type { IFeatureStrategy } from 'interfaces/strategy'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; import { useUiFlag } from 'hooks/useUiFlag'; import isEqual from 'lodash/isEqual'; +import { useReleasePlans } from 'hooks/api/getters/useReleasePlans/useReleasePlans'; +import { ReleasePlan } from '../ReleasePlan/ReleasePlan'; +import { SectionSeparator } from '../FeatureOverviewEnvironments/FeatureOverviewEnvironment/SectionSeparator/SectionSeparator'; +import { Badge } from 'component/common/Badge/Badge'; interface IEnvironmentAccordionBodyProps { isDisabled: boolean; @@ -41,6 +45,14 @@ const StyledAccordionBodyInnerContainer = styled('div')(({ theme }) => ({ }, })); +const StyledBadge = styled(Badge)(({ theme }) => ({ + backgroundColor: theme.palette.primary.light, + border: 'none', + padding: theme.spacing(0.75, 1.5), + borderRadius: theme.shape.borderRadiusLarge, + color: theme.palette.common.white, +})); + export const FeatureOverviewEnvironmentBody = ({ featureEnvironment, isDisabled, @@ -59,6 +71,11 @@ export const FeatureOverviewEnvironmentBody = ({ const [strategies, setStrategies] = useState( featureEnvironment?.strategies || [], ); + const { releasePlans } = useReleasePlans( + projectId, + featureId, + featureEnvironment?.name, + ); const { trackEvent } = usePlausibleTracker(); const [dragItem, setDragItem] = useState<{ @@ -215,7 +232,11 @@ export const FeatureOverviewEnvironmentBody = ({ 0 && isDisabled} + condition={ + (releasePlans.length > 0 || + strategiesToDisplay.length > 0) && + isDisabled + } show={() => ( This environment is disabled, which means that none @@ -224,78 +245,105 @@ export const FeatureOverviewEnvironmentBody = ({ )} /> 0} + condition={ + releasePlans.length > 0 || + strategiesToDisplay.length > 0 + } show={ - - {strategiesToDisplay.map( - (strategy, index) => ( + <> + {releasePlans.map((plan) => ( + + ))} + 0 && + strategies.length > 0 + } + show={ + + OR + + } + /> + + {strategiesToDisplay.map( + (strategy, index) => ( + + ), + )} + + } + elseShow={ + <> + + We noticed you're using a high + number of activation strategies. To + ensure a more targeted approach, + consider leveraging constraints or + segments. + +
+ {page.map((strategy, index) => ( {}) as any } - onDragStartRef={onDragStartRef} - onDragOver={onDragOver( - strategy.id, - )} - onDragEnd={onDragEnd} + onDragOver={(() => {}) as any} + onDragEnd={(() => {}) as any} /> - ), - )} - - } - elseShow={ - <> - - We noticed you're using a high number of - activation strategies. To ensure a more - targeted approach, consider leveraging - constraints or segments. - -
- {page.map((strategy, index) => ( - + + setPageIndex(page - 1) } - otherEnvironments={ - otherEnvironments - } - isDragging={false} - onDragStartRef={(() => {}) as any} - onDragOver={(() => {}) as any} - onDragEnd={(() => {}) as any} /> - ))} -
- - setPageIndex(page - 1) - } - /> - - } - /> + + } + /> + } elseShow={ prop !== 'disabled', +})<{ disabled?: boolean }>(({ theme, disabled }) => ({ + padding: theme.spacing(2), + borderRadius: theme.shape.borderRadiusMedium, + border: `1px solid ${theme.palette.divider}`, + '& + &': { + marginTop: theme.spacing(2), + }, + background: disabled + ? theme.palette.envAccordion.disabled + : theme.palette.background.paper, +})); + +const StyledHeader = styled('div', { + shouldForwardProp: (prop) => prop !== 'disabled', +})<{ disabled?: boolean }>(({ theme, disabled }) => ({ + display: 'flex', + justifyContent: 'space-between', + color: disabled ? theme.palette.text.secondary : theme.palette.text.primary, +})); + +const StyledHeaderTitleContainer = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + gap: theme.spacing(1), +})); + +const StyledHeaderTitleLabel = styled('span')(({ theme }) => ({ + fontSize: theme.fontSizes.smallerBody, + lineHeight: 0.5, + color: theme.palette.text.secondary, + marginBottom: theme.spacing(0.5), +})); + +const StyledHeaderDescription = styled('span')(({ theme }) => ({ + fontSize: theme.fontSizes.smallBody, + lineHeight: 0.5, + color: theme.palette.text.secondary, +})); + +const StyledBody = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + marginTop: theme.spacing(3), +})); + +const StyledConnection = styled('div')(({ theme }) => ({ + width: 4, + height: theme.spacing(2), + backgroundColor: theme.palette.divider, + marginLeft: theme.spacing(3.25), +})); + +interface IReleasePlanProps { + plan: IReleasePlan; +} + +export const ReleasePlan = ({ plan }: IReleasePlanProps) => { + const { + id, + name, + description, + activeMilestoneId, + featureName, + environment, + milestones, + } = plan; + + const projectId = useRequiredPathParam('projectId'); + const { refetch } = useReleasePlans(projectId, featureName, environment); + const { removeReleasePlanFromFeature, startReleasePlanMilestone } = + useReleasePlansApi(); + const { setToastData, setToastApiError } = useToast(); + + const [removeOpen, setRemoveOpen] = useState(false); + + const onRemoveConfirm = async () => { + try { + await removeReleasePlanFromFeature( + projectId, + featureName, + environment, + id, + ); + setToastData({ + title: `Release plan "${name}" has been removed from ${featureName} in ${environment}`, + type: 'success', + }); + refetch(); + setRemoveOpen(false); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const onStartMilestone = async (milestone: IReleasePlanMilestone) => { + try { + await startReleasePlanMilestone( + projectId, + featureName, + environment, + id, + milestone.id, + ); + setToastData({ + title: `Milestone "${milestone.name}" has started`, + type: 'success', + }); + refetch(); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const disabled = !activeMilestoneId; + const activeIndex = milestones.findIndex( + (milestone) => milestone.id === activeMilestoneId, + ); + + return ( + + + + + Release plan + + {name} + + {description} + + + setRemoveOpen(true)} + permission={DELETE_FEATURE_STRATEGY} + environmentId={environment} + projectId={projectId} + tooltipProps={{ + title: 'Remove release plan', + }} + > + + + + + {milestones.map((milestone, index) => ( +
+ + } + /> +
+ ))} +
+ +
+ ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone.tsx new file mode 100644 index 0000000000..3213ce37f9 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone.tsx @@ -0,0 +1,146 @@ +import ExpandMore from '@mui/icons-material/ExpandMore'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Link, + styled, +} from '@mui/material'; +import PlayCircleIcon from '@mui/icons-material/PlayCircle'; +import TripOriginIcon from '@mui/icons-material/TripOrigin'; +import type { IReleasePlanMilestone } from 'interfaces/releasePlans'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { ReleasePlanMilestoneStrategy } from './ReleasePlanMilestoneStrategy'; +import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator'; + +type MilestoneStatus = 'not-started' | 'active' | 'completed'; + +const StyledAccordion = styled(Accordion, { + shouldForwardProp: (prop) => prop !== 'status', +})<{ status: MilestoneStatus }>(({ theme, status }) => ({ + border: `1px solid ${status === 'active' ? theme.palette.success.border : theme.palette.divider}`, + boxShadow: 'none', + margin: 0, + backgroundColor: theme.palette.background.paper, + '&:before': { + display: 'none', + }, +})); + +const StyledAccordionSummary = styled(AccordionSummary)({ + '& .MuiAccordionSummary-content': { + justifyContent: 'space-between', + alignItems: 'center', + }, +}); + +const StyledTitleContainer = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'start', + flexDirection: 'column', + gap: theme.spacing(0.5), +})); + +const StyledTitle = styled('span')(({ theme }) => ({ + fontWeight: theme.fontWeight.bold, +})); + +const StyledStatus = styled('div', { + shouldForwardProp: (prop) => prop !== 'status', +})<{ status: MilestoneStatus }>(({ theme, status }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + paddingRight: theme.spacing(1), + fontSize: theme.fontSizes.smallerBody, + borderRadius: theme.shape.borderRadiusMedium, + backgroundColor: + status === 'active' ? theme.palette.success.light : 'transparent', + color: + status === 'active' + ? theme.palette.success.contrastText + : status === 'completed' + ? theme.palette.text.secondary + : theme.palette.text.primary, + '& svg': { + color: + status === 'active' + ? theme.palette.success.main + : status === 'completed' + ? theme.palette.neutral.border + : theme.palette.primary.main, + }, +})); + +const StyledSecondaryLabel = styled('span')(({ theme }) => ({ + color: theme.palette.text.secondary, + fontSize: theme.fontSizes.smallBody, +})); + +const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({ + backgroundColor: theme.palette.envAccordion.expanded, + borderBottomLeftRadius: theme.shape.borderRadiusLarge, + borderBottomRightRadius: theme.shape.borderRadiusLarge, +})); + +interface IReleasePlanMilestoneProps { + milestone: IReleasePlanMilestone; + status: MilestoneStatus; + onStartMilestone: (milestone: IReleasePlanMilestone) => void; +} + +export const ReleasePlanMilestone = ({ + milestone, + status, + onStartMilestone, +}: IReleasePlanMilestoneProps) => { + const statusText = + status === 'active' + ? 'Running' + : status === 'completed' + ? 'Restart' + : 'Start'; + + return ( + + }> + + {milestone.name} + + } + elseShow={} + /> + {statusText}} + elseShow={ + { + e.stopPropagation(); + onStartMilestone(milestone); + }} + > + {statusText} + + } + /> + + + View strategies + + + {milestone.strategies.map((strategy, index) => ( +
+ 0} + show={} + /> + +
+ ))} +
+
+ ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneStrategy.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneStrategy.tsx new file mode 100644 index 0000000000..7ce6d6421a --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneStrategy.tsx @@ -0,0 +1,55 @@ +import { Box, styled } from '@mui/material'; +import { StrategyExecution } from '../FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/StrategyExecution'; +import SplitPreviewSlider from 'component/feature/StrategyTypes/SplitPreviewSlider/SplitPreviewSlider'; +import { + formatStrategyName, + getFeatureStrategyIcon, +} from 'utils/strategyNames'; +import type { IFeatureStrategy } from 'interfaces/strategy'; + +const StyledStrategy = styled('div')(({ theme }) => ({ + background: theme.palette.background.paper, + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadiusMedium, + padding: theme.spacing(2), +})); + +const StyledHeader = styled('div')(({ theme }) => ({ + display: 'flex', + gap: theme.spacing(1), + alignItems: 'center', + color: theme.palette.text.primary, + '& > svg': { + fill: theme.palette.action.disabled, + }, + marginBottom: theme.spacing(1), +})); + +interface IReleasePlanMilestoneStrategyProps { + strategy: IFeatureStrategy; +} + +export const ReleasePlanMilestoneStrategy = ({ + strategy, +}: IReleasePlanMilestoneStrategyProps) => { + const Icon = getFeatureStrategyIcon(strategy.strategyName); + + return ( + + + + {`${formatStrategyName(String(strategy.strategyName))}${strategy.title ? `: ${strategy.title}` : ''}`} + + + {strategy.variants && + strategy.variants.length > 0 && + (strategy.disabled ? ( + + + + ) : ( + + ))} + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanRemoveDialog.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanRemoveDialog.tsx new file mode 100644 index 0000000000..6a87a859e9 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanRemoveDialog.tsx @@ -0,0 +1,43 @@ +import { Alert } from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import type { IReleasePlan } from 'interfaces/releasePlans'; + +interface IReleasePlanRemoveDialogProps { + plan: IReleasePlan; + open: boolean; + setOpen: React.Dispatch>; + onConfirm: () => void; +} + +export const ReleasePlanRemoveDialog = ({ + plan, + open, + setOpen, + onConfirm, +}: IReleasePlanRemoveDialogProps) => ( + setOpen(false)} + > + + This release plan currently has one active milestone. + Removing the release plan will change which users receive + access to the feature. +
+ } + /> +

+ You are about to remove release plan {plan.name}{' '} + from {plan.featureName} in{' '} + {plan.environment} +

+ +); diff --git a/frontend/src/component/releases/ReleasePlanTemplate/MilestoneCard.tsx b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneCard.tsx index a0a3241128..37e4fbbf37 100644 --- a/frontend/src/component/releases/ReleasePlanTemplate/MilestoneCard.tsx +++ b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneCard.tsx @@ -58,7 +58,7 @@ interface IMilestoneCardProps { milestoneNameChanged: (milestoneId: string, name: string) => void; showAddStrategyDialog: ( milestoneId: string, - strategy: IReleasePlanMilestoneStrategy, + strategy: Omit, ) => void; errors: { [key: string]: string }; clearErrors: () => void; @@ -84,7 +84,7 @@ export const MilestoneCard = ({ const onSelectStrategy = ( milestoneId: string, - strategy: IReleasePlanMilestoneStrategy, + strategy: Omit, ) => { showAddStrategyDialog(milestone.id, strategy); }; diff --git a/frontend/src/component/releases/ReleasePlanTemplate/MilestoneList.tsx b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneList.tsx index a628ab2a19..ecf161a29b 100644 --- a/frontend/src/component/releases/ReleasePlanTemplate/MilestoneList.tsx +++ b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneList.tsx @@ -15,7 +15,7 @@ interface IMilestoneListProps { >; openAddStrategyForm: ( milestoneId: string, - strategy: IReleasePlanMilestoneStrategy, + strategy: Omit, ) => void; errors: { [key: string]: string }; clearErrors: () => void; diff --git a/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyMenuCard.tsx b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyMenuCard.tsx index dcbd2a4710..ad7961dc05 100644 --- a/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyMenuCard.tsx +++ b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyMenuCard.tsx @@ -51,7 +51,9 @@ const StyledCard = styled('div')(({ theme }) => ({ interface IMilestoneStrategyMenuCardProps { strategy: IStrategy; - onClick: (strategy: IReleasePlanMilestoneStrategy) => void; + onClick: ( + strategy: Omit, + ) => void; } export const MilestoneStrategyMenuCard = ({ diff --git a/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyMenuCards.tsx b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyMenuCards.tsx index 9aed878fdc..da48e01219 100644 --- a/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyMenuCards.tsx +++ b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyMenuCards.tsx @@ -12,7 +12,7 @@ interface IMilestoneStrategyMenuCardsProps { milestoneId: string; openAddStrategy: ( milestoneId: string, - strategy: IReleasePlanMilestoneStrategy, + strategy: Omit, ) => void; } @@ -26,7 +26,9 @@ export const MilestoneStrategyMenuCards = ({ (strategy) => !strategy.deprecated && !strategy.editable, ); - const onClick = (strategy: IReleasePlanMilestoneStrategy) => { + const onClick = ( + strategy: Omit, + ) => { openAddStrategy(milestoneId, strategy); }; diff --git a/frontend/src/component/releases/ReleasePlanTemplate/ReleasePlanTemplateAddStrategyForm.tsx b/frontend/src/component/releases/ReleasePlanTemplate/ReleasePlanTemplateAddStrategyForm.tsx index 07756bd119..2f3089d77d 100644 --- a/frontend/src/component/releases/ReleasePlanTemplate/ReleasePlanTemplateAddStrategyForm.tsx +++ b/frontend/src/component/releases/ReleasePlanTemplate/ReleasePlanTemplateAddStrategyForm.tsx @@ -66,10 +66,10 @@ const StyledTargetingHeader = styled('div')(({ theme }) => ({ interface IReleasePlanTemplateAddStrategyFormProps { milestoneId: string | undefined; onCancel: () => void; - strategy: IReleasePlanMilestoneStrategy; + strategy: Omit; onAddStrategy: ( milestoneId: string, - strategy: IReleasePlanMilestoneStrategy, + strategy: Omit, ) => void; } @@ -135,7 +135,7 @@ export const ReleasePlanTemplateAddStrategyForm = ({ {activeTab === 0 && ( <> updateParameter('title', title) } diff --git a/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm.tsx b/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm.tsx index ab9efe7228..58bfd823f4 100644 --- a/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm.tsx +++ b/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm.tsx @@ -62,7 +62,9 @@ export const TemplateForm: React.FC = ({ const [activeMilestoneId, setActiveMilestoneId] = useState< string | undefined >(); - const [strategy, setStrategy] = useState({ + const [strategy, setStrategy] = useState< + Omit + >({ name: 'flexibleRollout', parameters: { rollout: '50' }, constraints: [], @@ -71,7 +73,7 @@ export const TemplateForm: React.FC = ({ }); const openAddStrategyForm = ( milestoneId: string, - strategy: IReleasePlanMilestoneStrategy, + strategy: Omit, ) => { setActiveMilestoneId(milestoneId); setStrategy(strategy); @@ -80,7 +82,7 @@ export const TemplateForm: React.FC = ({ const addStrategy = ( milestoneId: string, - strategy: IReleasePlanMilestoneStrategy, + strategy: Omit, ) => { setMilestones((prev) => prev.map((milestone, i) => diff --git a/frontend/src/hooks/api/actions/useReleasePlansApi/useReleasePlansApi.ts b/frontend/src/hooks/api/actions/useReleasePlansApi/useReleasePlansApi.ts index 0c04b14cdb..2ff9069dd5 100644 --- a/frontend/src/hooks/api/actions/useReleasePlansApi/useReleasePlansApi.ts +++ b/frontend/src/hooks/api/actions/useReleasePlansApi/useReleasePlansApi.ts @@ -25,7 +25,36 @@ export const useReleasePlansApi = () => { await makeRequest(req.caller, req.id); }; + const removeReleasePlanFromFeature = async ( + projectId: string, + featureName: string, + environment: string, + releasePlanId: string, + ): Promise => { + const requestId = 'removeReleasePlanFromFeature'; + const path = `api/admin/projects/${projectId}/features/${featureName}/environments/${environment}/release_plans/${releasePlanId}`; + const req = createRequest(path, { method: 'DELETE' }, requestId); + + await makeRequest(req.caller, req.id); + }; + + const startReleasePlanMilestone = async ( + projectId: string, + featureName: string, + environment: string, + releasePlanId: string, + milestoneId: string, + ): Promise => { + const requestId = 'startReleasePlanMilestone'; + const path = `api/admin/projects/${projectId}/features/${featureName}/environments/${environment}/release_plans/${releasePlanId}/milestones/${milestoneId}/start`; + const req = createRequest(path, { method: 'POST' }, requestId); + + await makeRequest(req.caller, req.id); + }; + return { addReleasePlanToFeature, + removeReleasePlanFromFeature, + startReleasePlanMilestone, }; }; diff --git a/frontend/src/hooks/api/getters/useReleasePlans/useReleasePlans.ts b/frontend/src/hooks/api/getters/useReleasePlans/useReleasePlans.ts new file mode 100644 index 0000000000..5a12c36efc --- /dev/null +++ b/frontend/src/hooks/api/getters/useReleasePlans/useReleasePlans.ts @@ -0,0 +1,43 @@ +import { useMemo } from 'react'; +import useUiConfig from '../useUiConfig/useUiConfig'; +import { formatApiPath } from 'utils/formatPath'; +import handleErrorResponses from '../httpErrorResponseHandler'; +import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR'; +import { useUiFlag } from 'hooks/useUiFlag'; +import type { IReleasePlan } from 'interfaces/releasePlans'; + +const DEFAULT_DATA: IReleasePlan[] = []; + +export const useReleasePlans = ( + projectId: string, + featureName: string, + environment?: string, +) => { + const { isEnterprise } = useUiConfig(); + const releasePlansEnabled = useUiFlag('releasePlans'); + + const { data, error, mutate } = useConditionalSWR( + isEnterprise() && releasePlansEnabled && Boolean(environment), + DEFAULT_DATA, + formatApiPath( + `api/admin/projects/${projectId}/features/${featureName}/environments/${environment}/release_plans`, + ), + fetcher, + ); + + return useMemo( + () => ({ + releasePlans: data ?? [], + loading: !error && !data, + refetch: () => mutate(), + error, + }), + [data, error, mutate], + ); +}; + +const fetcher = (path: string) => { + return fetch(path) + .then(handleErrorResponses('Release plans')) + .then((res) => res.json()); +}; diff --git a/frontend/src/interfaces/releasePlans.ts b/frontend/src/interfaces/releasePlans.ts index 09ad8b910f..4c50d52503 100644 --- a/frontend/src/interfaces/releasePlans.ts +++ b/frontend/src/interfaces/releasePlans.ts @@ -1,5 +1,4 @@ -import type { IFeatureVariant } from './featureToggle'; -import type { IConstraint, IFeatureStrategyParameters } from './strategy'; +import type { IFeatureStrategy } from './strategy'; export interface IReleasePlanTemplate { id: string; @@ -18,19 +17,27 @@ export interface IReleasePlanTemplate { milestones: IReleasePlanMilestonePayload[]; } -export interface IReleasePlanMilestoneStrategy { +export interface IReleasePlan { id: string; name: string; - title: string; - disabled?: boolean; - constraints: IConstraint[]; - parameters: IFeatureStrategyParameters; - variants?: IFeatureVariant[]; + description: string; + createdAt: string; + createdByUserId: number; + activeMilestoneId?: string; + featureName: string; + environment: string; + milestones: IReleasePlanMilestone[]; } export interface IReleasePlanMilestone { id: string; name: string; + releasePlanDefinitionId: string; + strategies: IReleasePlanMilestoneStrategy[]; +} + +export interface IReleasePlanMilestoneStrategy extends IFeatureStrategy { + milestoneId: string; } export interface IReleasePlanTemplatePayload { diff --git a/frontend/src/utils/strategyNames.tsx b/frontend/src/utils/strategyNames.tsx index 2ae68f7f1a..b3a1db9d8b 100644 --- a/frontend/src/utils/strategyNames.tsx +++ b/frontend/src/utils/strategyNames.tsx @@ -20,7 +20,7 @@ const RolloutSvgIcon: FC = (props) => ( /> ); -export const getFeatureStrategyIcon = (strategyName: string) => { +export const getFeatureStrategyIcon = (strategyName?: string) => { switch (strategyName) { case 'default': return PowerSettingsNewIcon;