1
0
mirror of https://github.com/Unleash/unleash.git synced 2026-02-04 20:10:52 +01:00

feat: Suggest release templates for production environments (#11279)

Adds a "Choose a release template" suggestion for production
environments without strategies (enterprise only).
When clicked, opens the "Add Strategy" dialog with the release templates
filter preselected.
Non-production environments continue to show the default strategy
suggestion.

## Notes 

This unfortunately turned out to be a big PR 🥲 as it includes some
refactoring to be able to reuse components.
- The "Add strategy" button has been broken out of
`FeatureStrategyMenu`, so the latter can be reused (since we want to
show the same dialog when clicking the button "Choose a release
template");
- The new `EnvironmentTemplateSuggestion` shares styles with
`EnvironmentStrategySuggestion` (which can be found in
`EnvironmentHeader.styles.tsx`);
- `FeatureStrategyMenu` now has a `defaultFilter` prop, so the dialog
can be opened with a preselected filter.

<img width="900" height="349" alt="Screenshot 2026-02-03 at 17 19 32
(2)"
src="https://github.com/user-attachments/assets/27f11e24-163f-4f4d-8134-a5d08ff540ac"
/>
<img width="1379001" height="557" alt="Screenshot 2026-02-03 at 17 20
14"
src="https://github.com/user-attachments/assets/0efe77f5-af3e-498a-b305-fd5c1ed98906"
/>
This commit is contained in:
Kamala 2026-02-04 11:34:42 +01:00 committed by GitHub
parent b955761927
commit be130979ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 414 additions and 104 deletions

View File

@ -47,6 +47,7 @@ export const QuickFilters = <T extends string | null>({
label={label}
variant='outlined'
isActive={value === currentValue}
aria-pressed={value === currentValue}
onClick={() => onChange(value)}
/>
))}

View File

@ -1,9 +1,4 @@
import type React from 'react';
import { useEffect, useState } from 'react';
import PermissionButton, {
type IPermissionButtonProps,
} from 'component/common/PermissionButton/PermissionButton';
import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
import { Box, Dialog, IconButton, styled, Typography } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
@ -23,22 +18,14 @@ import {
import { ReleasePlanConfirmationDialog } from './ReleasePlanConfirmationDialog.tsx';
interface IFeatureStrategyMenuProps {
label: string;
projectId: string;
featureId: string;
environmentId: string;
variant?: IPermissionButtonProps['variant'];
matchWidth?: boolean;
disableReason?: string;
isStrategyMenuDialogOpen: boolean;
onClose: any;
defaultFilter?: StrategyFilterValue;
}
const StyledStrategyMenu = styled('div')(({ theme }) => ({
display: 'flex',
flexFlow: 'row',
justifyContent: 'flex-end',
gap: theme.spacing(1),
}));
const StyledHeader = styled(Box)(({ theme }) => ({
display: 'flex',
justifyContent: 'space-between',
@ -47,26 +34,20 @@ const StyledHeader = styled(Box)(({ theme }) => ({
}));
export const FeatureStrategyMenu = ({
label,
projectId,
featureId,
environmentId,
variant,
matchWidth,
disableReason,
isStrategyMenuDialogOpen,
onClose,
defaultFilter = null,
}: IFeatureStrategyMenuProps) => {
const [isStrategyMenuDialogOpen, setIsStrategyMenuDialogOpen] =
useState<boolean>(false);
const [filter, setFilter] = useState<StrategyFilterValue>(null);
const [filter, setFilter] = useState<StrategyFilterValue>(defaultFilter);
const { trackEvent } = usePlausibleTracker();
const [selectedTemplate, setSelectedTemplate] =
useState<IReleasePlanTemplate>();
const [releasePlanPreview, setReleasePlanPreview] = useState(false);
const [addReleasePlanConfirmationOpen, setAddReleasePlanConfirmationOpen] =
useState(false);
const dialogId = isStrategyMenuDialogOpen
? 'FeatureStrategyMenuDialog'
: undefined;
const { setToastApiError, setToastData } = useToast();
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const { addChange } = useChangeRequestApi();
@ -82,18 +63,11 @@ export const FeatureStrategyMenu = ({
const activeReleasePlan = releasePlans[0];
const onClose = () => {
setIsStrategyMenuDialogOpen(false);
};
useEffect(() => {
if (!isStrategyMenuDialogOpen) return;
setReleasePlanPreview(false);
}, [isStrategyMenuDialogOpen]);
const openMoreStrategies = (_event: React.SyntheticEvent) => {
setIsStrategyMenuDialogOpen(true);
};
setFilter(defaultFilter);
}, [isStrategyMenuDialogOpen, defaultFilter]);
const addReleasePlan = async (
template: IReleasePlanTemplate,
@ -150,23 +124,7 @@ export const FeatureStrategyMenu = ({
};
return (
<StyledStrategyMenu onClick={(event) => event.stopPropagation()}>
<PermissionButton
data-testid='ADD_STRATEGY_BUTTON'
permission={CREATE_FEATURE_STRATEGY}
projectId={projectId}
environmentId={environmentId}
onClick={openMoreStrategies}
aria-labelledby={dialogId}
variant={variant}
sx={{ minWidth: matchWidth ? '282px' : 'auto' }}
disabled={Boolean(disableReason)}
tooltipProps={{
title: disableReason ? disableReason : undefined,
}}
>
Add strategy
</PermissionButton>
<>
<Dialog
open={isStrategyMenuDialogOpen}
onClose={onClose}
@ -235,6 +193,6 @@ export const FeatureStrategyMenu = ({
}}
/>
)}
</StyledStrategyMenu>
</>
);
};

View File

@ -0,0 +1,55 @@
import PermissionButton, {
type IPermissionButtonProps,
} from 'component/common/PermissionButton/PermissionButton';
import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
import { styled } from '@mui/material';
interface IFeatureStrategyMenuButtonProps {
label: string;
projectId: string;
environmentId: string;
dialogId?: string;
onClick: any;
variant?: IPermissionButtonProps['variant'];
matchWidth?: boolean;
disableReason?: string;
}
const StyledStrategyMenu = styled('div')(({ theme }) => ({
display: 'flex',
flexFlow: 'row',
justifyContent: 'flex-end',
gap: theme.spacing(1),
}));
export const FeatureStrategyMenuButton = ({
label,
projectId,
environmentId,
dialogId,
onClick,
variant,
matchWidth,
disableReason,
}: IFeatureStrategyMenuButtonProps) => {
return (
<StyledStrategyMenu onClick={(event) => event.stopPropagation()}>
<PermissionButton
data-testid='ADD_STRATEGY_BUTTON'
permission={CREATE_FEATURE_STRATEGY}
projectId={projectId}
environmentId={environmentId}
onClick={onClick}
aria-labelledby={dialogId}
variant={variant}
sx={{ minWidth: matchWidth ? '282px' : 'auto' }}
disabled={Boolean(disableReason)}
tooltipProps={{
title: disableReason ? disableReason : undefined,
}}
>
{label}
</PermissionButton>
</StyledStrategyMenu>
);
};

View File

@ -0,0 +1,35 @@
import { Box, styled } from '@mui/material';
export const StyledSuggestion = styled('div')(({ theme }) => ({
width: '100%',
display: 'flex',
alignItems: 'center',
padding: theme.spacing(0.5, 3),
background: theme.palette.secondary.light,
borderBottomLeftRadius: theme.shape.borderRadiusLarge,
borderBottomRightRadius: theme.shape.borderRadiusLarge,
color: theme.palette.primary.main,
fontSize: theme.fontSizes.smallerBody,
}));
export const StyledBold = styled('b')(({ theme }) => ({
fontWeight: theme.typography.fontWeightBold,
}));
export const StyledSpan = styled('span')(({ theme }) => ({
fontWeight: theme.typography.fontWeightBold,
textDecoration: 'underline',
}));
export const TooltipHeader = styled('div')(({ theme }) => ({
fontWeight: theme.typography.fontWeightBold,
}));
export const TooltipDescription = styled('div')(({ theme }) => ({
fontSize: theme.fontSizes.smallerBody,
paddingBottom: theme.spacing(1.5),
}));
export const StyledBox = styled(Box)(({ theme }) => ({
padding: theme.spacing(1.5),
}));

View File

@ -10,6 +10,7 @@ import { useId } from 'hooks/useId';
import { EnvironmentStrategySuggestion } from './EnvironmentStrategySuggestion/EnvironmentStrategySuggestion.js';
import type { IFeatureStrategy } from 'interfaces/strategy';
import { useProjectEnvironments } from 'hooks/api/getters/useProjectEnvironments/useProjectEnvironments';
import { EnvironmentTemplateSuggestion } from './EnvironmentTemplateSuggestion/EnvironmentTemplateSuggestion';
const StyledAccordionSummary = styled(AccordionSummary, {
shouldForwardProp: (prop) => prop !== 'expandable' && prop !== 'empty',
@ -109,6 +110,7 @@ type EnvironmentHeaderProps = {
expandable?: boolean;
environmentMetadata?: EnvironmentMetadata;
hasActivations?: boolean;
onOpenReleaseTemplates?: any;
} & AccordionSummaryProps;
const MetadataChip = ({
@ -162,13 +164,14 @@ export const EnvironmentHeader: FC<
expandable = true,
environmentMetadata,
hasActivations = false,
onOpenReleaseTemplates,
...props
}) => {
const id = useId();
const { environments } = useProjectEnvironments(projectId);
const defaultStrategy = environments.find(
(env) => env.name === environmentId,
)?.defaultStrategy;
const environment = environments.find((env) => env.name === environmentId);
const defaultStrategy = environment?.defaultStrategy;
const environmentType = environment?.type;
const strategy: Omit<IFeatureStrategy, 'id'> = useMemo(() => {
const baseDefaultStrategy = {
@ -211,7 +214,7 @@ export const EnvironmentHeader: FC<
</StyledHeaderTitle>
{children}
</StyledHeader>
{!hasActivations && (
{!hasActivations && environmentType !== 'production' && (
<EnvironmentStrategySuggestion
projectId={projectId}
featureId={featureId}
@ -219,6 +222,13 @@ export const EnvironmentHeader: FC<
strategy={strategy}
/>
)}
{!hasActivations &&
environmentType === 'production' &&
onOpenReleaseTemplates && (
<EnvironmentTemplateSuggestion
onClick={onOpenReleaseTemplates}
/>
)}
</StyledAccordionSummary>
);
};

View File

@ -1,4 +1,3 @@
import { Box, styled } from '@mui/material';
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
import { Link } from 'react-router-dom';
import { StrategyExecution } from '../../EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/StrategyExecution.js';
@ -12,40 +11,14 @@ import useToast from 'hooks/useToast.js';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests.js';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature.js';
import useFeatureStrategyApi from 'hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi.js';
const StyledSuggestion = styled('div')(({ theme }) => ({
width: '100%',
display: 'flex',
alignItems: 'center',
padding: theme.spacing(0.5, 3),
background: theme.palette.secondary.light,
borderBottomLeftRadius: theme.shape.borderRadiusLarge,
borderBottomRightRadius: theme.shape.borderRadiusLarge,
color: theme.palette.primary.main,
fontSize: theme.fontSizes.smallerBody,
}));
const StyledBold = styled('b')(({ theme }) => ({
fontWeight: theme.typography.fontWeightBold,
}));
const StyledSpan = styled('span')(({ theme }) => ({
fontWeight: theme.typography.fontWeightBold,
textDecoration: 'underline',
}));
const TooltipHeader = styled('div')(({ theme }) => ({
fontWeight: theme.typography.fontWeightBold,
}));
const TooltipDescription = styled('div')(({ theme }) => ({
fontSize: theme.fontSizes.smallerBody,
paddingBottom: theme.spacing(1.5),
}));
const StyledBox = styled(Box)(({ theme }) => ({
padding: theme.spacing(1.5),
}));
import {
StyledBold,
StyledBox,
StyledSpan,
StyledSuggestion,
TooltipDescription,
TooltipHeader,
} from '../EnvironmentHeader.styles';
type DefaultStrategySuggestionProps = {
projectId: string;

View File

@ -0,0 +1,48 @@
import { Button } from '@mui/material';
import { Link } from 'react-router-dom';
import {
StyledBold,
StyledBox,
StyledSpan,
StyledSuggestion,
TooltipDescription,
} from '../EnvironmentHeader.styles';
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
type EnvironmentTemplateSuggestionProps = {
onClick: () => void;
};
export const EnvironmentTemplateSuggestion = ({
onClick,
}: EnvironmentTemplateSuggestionProps) => {
return (
<StyledSuggestion>
<StyledBold>Suggestion:</StyledBold>
&nbsp;Add a&nbsp;
<HtmlTooltip
title={
<StyledBox>
<TooltipDescription>
Release templates are defined globally&nbsp;
<Link
to='/release-templates'
title='Release templates'
>
here
</Link>
</TooltipDescription>
</StyledBox>
}
maxWidth='200'
arrow
>
<StyledSpan>release template</StyledSpan>
</HtmlTooltip>
&nbsp;to this environment&nbsp;
<Button size='small' variant='text' onClick={onClick}>
Choose a release template
</Button>
</StyledSuggestion>
);
};

View File

@ -4,6 +4,10 @@ import { render } from 'utils/testRenderer';
import { FeatureOverviewEnvironment } from './FeatureOverviewEnvironment.tsx';
import { Route, Routes } from 'react-router-dom';
import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
import { testServerRoute, testServerSetup } from 'utils/testServer';
import userEvent from '@testing-library/user-event';
const server = testServerSetup();
const renderRoute = (element: ReactNode, permissions: any[] = []) =>
render(
@ -19,6 +23,54 @@ const renderRoute = (element: ReactNode, permissions: any[] = []) =>
},
);
const setupEnterpriseEndpoints = () => {
testServerRoute(server, '/api/admin/ui-config', {
versionInfo: {
current: {
enterprise: '1.0.0',
},
},
environment: 'enterprise',
});
testServerRoute(server, '/api/admin/release-plan-templates', [
{
id: 'template-1',
name: 'Test Template',
description: 'A test release template',
},
]);
testServerRoute(server, '/api/admin/environments/project/default', {
environments: [
{
name: 'production',
enabled: true,
type: 'production',
},
],
});
};
const setupOssEndpoints = () => {
testServerRoute(server, '/api/admin/ui-config', {
versionInfo: {
current: {},
},
flags: {},
resourceLimits: {
featureEnvironmentStrategies: 30,
},
});
testServerRoute(server, '/api/admin/environments/project/default', {
environments: [
{
name: 'production',
enabled: true,
type: 'production',
},
],
});
};
describe('FeatureOverviewEnvironment', () => {
test('should allow to add strategy', async () => {
renderRoute(
@ -63,4 +115,131 @@ describe('FeatureOverviewEnvironment', () => {
const button = await screen.findByText('Add strategy');
expect(button).toHaveAttribute('aria-disabled', 'true');
});
test('shows release template suggestion for production environment on enterprise', async () => {
setupEnterpriseEndpoints();
renderRoute(
<FeatureOverviewEnvironment
environment={{
name: 'production',
enabled: false,
type: 'production',
strategies: [],
}}
/>,
[{ permission: CREATE_FEATURE_STRATEGY }],
);
expect(
await screen.findByText('Choose a release template'),
).toBeInTheDocument();
});
test('does not show release template suggestion for non-production environment on enterprise', async () => {
setupEnterpriseEndpoints();
testServerRoute(server, '/api/admin/environments/project/default', {
environments: [
{
name: 'development',
enabled: true,
type: 'development',
sortOrder: 0,
},
],
});
renderRoute(
<FeatureOverviewEnvironment
environment={{
name: 'development',
enabled: false,
type: 'development',
strategies: [],
}}
/>,
[{ permission: CREATE_FEATURE_STRATEGY }],
);
expect(
await screen.findByText(/default strategy/i),
).toBeInTheDocument();
expect(
screen.queryByText('Choose a release template'),
).not.toBeInTheDocument();
});
test('does not show release template suggestion for production environment on OSS', async () => {
setupOssEndpoints();
renderRoute(
<FeatureOverviewEnvironment
environment={{
name: 'production',
enabled: false,
type: 'production',
strategies: [],
}}
/>,
[{ permission: CREATE_FEATURE_STRATEGY }],
);
expect(await screen.findByText('production')).toBeInTheDocument();
expect(
screen.queryByText('Choose a release template'),
).not.toBeInTheDocument();
});
test('does not show release template suggestion when environment has activations', async () => {
setupEnterpriseEndpoints();
renderRoute(
<FeatureOverviewEnvironment
environment={{
name: 'production',
enabled: true,
type: 'production',
strategies: [
{
id: '1',
name: 'flexibleRollout',
parameters: {},
constraints: [],
},
],
}}
/>,
[{ permission: CREATE_FEATURE_STRATEGY }],
);
expect(await screen.findByText('production')).toBeInTheDocument();
expect(
screen.queryByText('Choose a release template'),
).not.toBeInTheDocument();
});
test('opens strategy menu dialog with release templates filter when clicking release template suggestion', async () => {
const user = userEvent.setup();
setupEnterpriseEndpoints();
renderRoute(
<FeatureOverviewEnvironment
environment={{
name: 'production',
enabled: false,
type: 'production',
strategies: [],
}}
/>,
[{ permission: CREATE_FEATURE_STRATEGY }],
);
const releaseTemplateButton = await screen.findByText(
'Choose a release template',
);
await user.click(releaseTemplateButton);
const releaseTemplatesFilter = screen.queryByRole('button', {
name: /release templates/i,
});
expect(releaseTemplatesFilter).toBeInTheDocument();
expect(releaseTemplatesFilter).toHaveAttribute('aria-pressed', 'true');
});
});

View File

@ -1,4 +1,5 @@
import { Accordion, AccordionDetails, styled } from '@mui/material';
import { useState } from 'react';
import { Accordion, AccordionDetails, Box, styled } from '@mui/material';
import type {
IFeatureEnvironment,
IFeatureEnvironmentMetrics,
@ -14,10 +15,10 @@ import {
} from './EnvironmentHeader/EnvironmentHeader.tsx';
import FeatureOverviewEnvironmentMetrics from './EnvironmentHeader/FeatureOverviewEnvironmentMetrics/FeatureOverviewEnvironmentMetrics.tsx';
import { FeatureOverviewEnvironmentToggle } from './EnvironmentHeader/FeatureOverviewEnvironmentToggle/FeatureOverviewEnvironmentToggle.tsx';
import { useState } from 'react';
import type { IReleasePlan } from 'interfaces/releasePlans';
import { EnvironmentAccordionBody } from './EnvironmentAccordionBody/EnvironmentAccordionBody.tsx';
import { Box } from '@mui/material';
import type { StrategyFilterValue } from 'component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenuCards/FeatureStrategyMenuCards';
import { FeatureStrategyMenuButton } from 'component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenuButton.tsx';
const StyledFeatureOverviewEnvironment = styled('div')(({ theme }) => ({
borderRadius: theme.shape.borderRadiusLarge,
@ -73,13 +74,30 @@ export const FeatureOverviewEnvironment = ({
const [isOpen, setIsOpen] = useState(false);
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { isOss } = useUiConfig();
const { isOss, isEnterprise } = useUiConfig();
const hasActivations = Boolean(
environment?.enabled ||
(environment?.strategies && environment?.strategies.length > 0) ||
(environment?.releasePlans && environment?.releasePlans.length > 0),
);
const [filter, setFilter] = useState<StrategyFilterValue>(null);
const [isStrategyMenuDialogOpen, setIsStrategyMenuDialogOpen] =
useState<boolean>(false);
const dialogId = isStrategyMenuDialogOpen
? 'FeatureStrategyMenuDialog'
: undefined;
const openMoreStrategies = (_event: React.SyntheticEvent) => {
setFilter(null);
setIsStrategyMenuDialogOpen(true);
};
const onClose = () => {
setIsStrategyMenuDialogOpen(false);
};
return (
<StyledFeatureOverviewEnvironment>
<StyledAccordion
@ -102,18 +120,39 @@ export const FeatureOverviewEnvironment = ({
featureId={featureId}
expandable={hasActivations}
hasActivations={hasActivations}
onOpenReleaseTemplates={
isEnterprise()
? () => {
setFilter('releaseTemplates');
setIsStrategyMenuDialogOpen(true);
}
: undefined
}
>
<FeatureOverviewEnvironmentToggle
environment={environment}
/>
{!hasActivations ? (
<FeatureStrategyMenu
label='Add strategy'
projectId={projectId}
featureId={featureId}
environmentId={environment.name}
variant='outlined'
/>
<>
<FeatureStrategyMenuButton
label='Add strategy'
dialogId={dialogId}
projectId={projectId}
environmentId={environment.name}
onClick={openMoreStrategies}
variant='outlined'
/>
<FeatureStrategyMenu
projectId={projectId}
featureId={featureId}
environmentId={environment.name}
isStrategyMenuDialogOpen={
isStrategyMenuDialogOpen
}
onClose={onClose}
defaultFilter={filter}
/>
</>
) : (
<FeatureOverviewEnvironmentMetrics
environmentMetric={metrics}
@ -131,11 +170,23 @@ export const FeatureOverviewEnvironment = ({
<StyledAccordionFooter>
<Box sx={{ display: 'flex', flexDirection: 'row' }}>
<Box ml='auto'>
<FeatureStrategyMenu
<FeatureStrategyMenuButton
label='Add strategy'
dialogId={dialogId}
projectId={projectId}
environmentId={environment.name}
onClick={openMoreStrategies}
variant='outlined'
/>
<FeatureStrategyMenu
projectId={projectId}
featureId={featureId}
environmentId={environment.name}
isStrategyMenuDialogOpen={
isStrategyMenuDialogOpen
}
onClose={onClose}
defaultFilter={filter}
/>
</Box>
</Box>